feat(cli): add release automation CLI scripts (#35)
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s

feat(cli): add release automation CLI scripts

Add 6 new CLI scripts replacing inline bash in CI workflows.
Update version_set_platform.php with --stability flag.
This commit was merged in pull request #35.
This commit is contained in:
2026-05-22 02:28:17 +00:00
parent a706a2cc32
commit 5b9d258135
8 changed files with 1155 additions and 1 deletions
+1
View File
@@ -687,6 +687,7 @@ modulebuilder.txt
!/bin/moko
/cache/*
/cli/*
!/cli/*.php
/components/com_ajax/*
/components/com_banners/*
/components/com_config/*
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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/badge_update.php
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
*
* Usage:
* php badge_update.php --path /repo --version 04.01.00
*/
declare(strict_types=1);
$path = '.';
$version = null;
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 ($version === null) {
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n");
exit(1);
}
$root = realpath($path) ?: $path;
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
$replacement = "[VERSION: {$version}]";
$updated = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
// Skip .git and vendor directories
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
continue;
}
// Only process markdown files
if (!preg_match('/\.md$/i', $filePath)) {
continue;
}
$content = file_get_contents($filePath);
if (preg_match($pattern, $content)) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
file_put_contents($filePath, $newContent);
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
echo "Updated: {$relative}\n";
$updated++;
}
}
}
echo "Updated {$updated} file(s) to {$replacement}\n";
exit(0);
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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/changelog_promote.php
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
*
* Usage:
* php changelog_promote.php --path /repo --version 04.01.00
* php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21
*/
declare(strict_types=1);
$path = '.';
$version = null;
$date = date('Y-m-d');
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 === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1];
}
if ($version === null) {
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n");
exit(1);
}
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n");
exit(1);
}
$content = file_get_contents($changelog);
// Check if [Unreleased] section exists
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n");
exit(1);
}
// Replace [Unreleased] with versioned entry
$content = preg_replace(
'/## \[Unreleased\]/i',
"## [{$version}] --- {$date}",
$content,
1
);
$content = preg_replace(
'/## Unreleased/i',
"## [{$version}] --- {$date}",
$content,
1
);
// Insert new [Unreleased] section after the first heading line (# Changelog)
$lines = explode("\n", $content);
$inserted = false;
$result = [];
foreach ($lines as $line) {
$result[] = $line;
if (!$inserted && preg_match('/^# /', $line)) {
$result[] = '';
$result[] = '## [Unreleased]';
$result[] = '';
$inserted = true;
}
}
$content = implode("\n", $result);
file_put_contents($changelog, $content);
echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n";
exit(0);
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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/package_build.php
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
*
* Usage:
* php package_build.php --path /repo --version 04.01.00
* php package_build.php --path /repo --version 04.01.00 --output-dir /tmp
* php package_build.php --path /repo --version 04.01.00 --github-output
*
* Options:
* --path Repository root (default: .)
* --version Version string (required)
* --output-dir Directory for built packages (default: /tmp)
* --type-prefix Override type prefix (e.g. plg_system_)
* --element Override element name
* --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT
*
* NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped.
*/
declare(strict_types=1);
$path = '.';
$version = null;
$outputDir = '/tmp';
$typePrefixOverride = null;
$elementOverride = null;
$githubOutput = false;
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 === '--output-dir' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
if ($arg === '--type-prefix' && isset($argv[$i + 1])) $typePrefixOverride = $argv[$i + 1];
if ($arg === '--element' && isset($argv[$i + 1])) $elementOverride = $argv[$i + 1];
if ($arg === '--github-output') $githubOutput = true;
}
if ($version === null) {
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
exit(1);
}
$root = realpath($path) ?: $path;
// -- Determine source directory -----------------------------------------------
$sourceDir = null;
foreach (['src', 'htdocs'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}";
break;
}
}
if ($sourceDir === null) {
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
exit(1);
}
// -- Determine element and type prefix from manifest --------------------------
$extElement = $elementOverride;
$typePrefix = $typePrefixOverride ?? '';
$extType = '';
$isPackage = false;
if ($extElement === null || $typePrefixOverride === null) {
// Find manifest
$manifest = null;
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
if ($manifest === null) {
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
}
if ($manifest !== null) {
$xml = file_get_contents($manifest);
if ($extElement === null) {
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
elseif (preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
else $extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
}
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
if ($typePrefixOverride === null) {
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;
}
}
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
}
}
if ($extElement === null) {
$extElement = strtolower(basename($root));
}
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz";
$zipPath = "{$outputDir}/{$zipName}";
$tarPath = "{$outputDir}/{$tarName}";
// -- Exclude patterns ---------------------------------------------------------
$excludePatterns = [
'.ftpignore',
'sftp-config*',
'*.ppk',
'*.pem',
'*.key',
'.env*',
];
// -- Build packages -----------------------------------------------------------
if ($isPackage) {
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
mkdir($stagingDir, 0755, true);
// ZIP each sub-extension
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
$subName = basename($extDir);
echo " Packaging sub-extension: {$subName}\n";
$subZip = new ZipArchive();
$subZipPath = "{$stagingDir}/{$subName}.zip";
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
continue;
}
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
$subZip->close();
}
// Copy package-level files
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
copy($f, "{$stagingDir}/" . basename($f));
}
// Create ZIP from staging
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $stagingDir, '', []);
$zip->close();
// Create tar.gz — all arguments are escaped via escapeshellarg()
$tarCmd = sprintf(
'tar -czf %s -C %s .',
escapeshellarg($tarPath),
escapeshellarg($stagingDir)
);
passthru($tarCmd, $tarReturn);
// Cleanup staging
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd);
} else {
echo "=== Building standard extension package ===\n";
// ZIP
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
// tar.gz — all arguments are escaped via escapeshellarg()
$excludeArgs = '';
foreach ($excludePatterns as $pattern) {
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
}
$tarCmd = sprintf(
'tar -czf %s -C %s%s .',
escapeshellarg($tarPath),
escapeshellarg($sourceDir),
$excludeArgs
);
passthru($tarCmd, $tarReturn);
}
// -- Calculate SHA-256 --------------------------------------------------------
$sha256Zip = hash_file('sha256', $zipPath);
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
$zipSize = filesize($zipPath);
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
echo "\n";
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
echo " SHA-256: {$sha256Zip}\n";
if ($tarSize > 0) {
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
echo " SHA-256: {$sha256Tar}\n";
}
// -- Export to GITHUB_OUTPUT --------------------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"zip_name={$zipName}",
"tar_name={$tarName}",
"zip_path={$zipPath}",
"tar_path={$tarPath}",
"sha256_zip={$sha256Zip}",
"sha256_tar={$sha256Tar}",
"type_prefix={$typePrefix}",
"ext_element={$extElement}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
} else {
foreach ($lines as $line) echo "{$line}\n";
}
}
exit(0);
// =============================================================================
// Helper: recursively add directory contents to a ZipArchive
// =============================================================================
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
// Check excludes
$basename = basename($filePath);
$skip = false;
foreach ($excludes as $pattern) {
if (fnmatch($pattern, $basename)) {
$skip = true;
break;
}
}
if ($skip) continue;
// Normalize path separators for ZIP
$relativePath = str_replace('\\', '/', $relativePath);
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
$zip->addFile($filePath, $relativePath);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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/release_cascade.php
* BRIEF: Delete lesser pre-release channels from Gitea when promoting stability
*
* Usage:
* php release_cascade.php --stability stable --token TOKEN --api-base URL
* php release_cascade.php --stability rc --token TOKEN --api-base URL
*
* Cascade rules:
* stable -> deletes development, alpha, beta, release-candidate
* rc -> deletes development, alpha, beta
* beta -> deletes development, alpha
* alpha -> deletes development
*/
declare(strict_types=1);
$stability = null;
$token = null;
$apiBase = null;
foreach ($argv as $i => $arg) {
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $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];
}
// Allow token from environment
if ($token === null) {
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
if ($stability === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
exit(1);
}
// Define cascade hierarchy
$cascadeMap = [
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
'rc' => ['development', 'alpha', 'beta'],
'beta' => ['development', 'alpha'],
'alpha' => ['development'],
];
if (!isset($cascadeMap[$stability])) {
fwrite(STDERR, "Unknown stability level: {$stability}\n");
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
exit(1);
}
$tagsToDelete = $cascadeMap[$stability];
$deleted = 0;
foreach ($tagsToDelete as $tag) {
// Get release by tag
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($response)) {
continue;
}
$data = json_decode($response, true);
$releaseId = $data['id'] ?? null;
if ($releaseId === null) {
continue;
}
// Delete release
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
curl_close($ch);
// Delete tag
$ch = curl_init("{$apiBase}/tags/{$tag}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
curl_close($ch);
echo "Deleted: {$tag} (release id: {$releaseId})\n";
$deleted++;
}
echo "Cleaned up {$deleted} pre-release channel(s)\n";
exit(0);
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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/release_manage.php
* BRIEF: Create/update Gitea releases, upload assets, update release body
*
* Usage:
* # Create a release
* php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \
* --body "Release notes" --target main --token TOKEN --api-base URL
*
* # Upload assets to a release
* php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \
* --token TOKEN --api-base URL
*
* # Update release body (e.g. add SHA checksums)
* php release_manage.php --action update-body --tag stable --body "New body" \
* --token TOKEN --api-base URL
*
* # Delete a release and its tag
* php release_manage.php --action delete --tag stable --token TOKEN --api-base URL
*
* Options:
* --action create | upload | update-body | delete (required)
* --tag Release tag name (required)
* --name Release name/title (for create)
* --body Release body/description (for create, update-body)
* --body-file Read body from file instead of --body
* --target Target branch/commitish (for create, default: main)
* --files Comma-separated file paths to upload (for upload)
* --token Gitea API token (or GA_TOKEN/GITEA_TOKEN env var)
* --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo)
*
* NOTE: This script uses PHP curl for all HTTP operations (no shell calls).
*/
declare(strict_types=1);
$action = null;
$tag = null;
$name = null;
$body = null;
$bodyFile = null;
$target = 'main';
$files = [];
$token = null;
$apiBase = null;
foreach ($argv as $i => $arg) {
if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1];
if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1];
if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1];
if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1];
if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1];
if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1];
if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $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];
}
// Allow token from environment
if ($token === null) {
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
// Read body from file if specified
if ($bodyFile !== null && file_exists($bodyFile)) {
$body = file_get_contents($bodyFile);
}
if ($action === null || $tag === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n");
exit(1);
}
/**
* Make a Gitea API request using curl
*/
function giteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{
$ch = curl_init($url);
$headers = ["Authorization: token {$token}"];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CUSTOMREQUEST => $method,
];
if ($jsonBody !== null) {
$headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
} elseif ($filePath !== null) {
$headers[] = 'Content-Type: application/octet-stream';
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response ?: '{}', true) ?: [];
return ['code' => $httpCode, 'data' => $data];
}
/**
* Get release by tag
*/
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = giteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
if ($result['code'] === 200 && isset($result['data']['id'])) {
return $result['data'];
}
return null;
}
// -- Action dispatch ----------------------------------------------------------
switch ($action) {
case 'create':
// Delete existing release if present
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
giteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
$payload = json_encode([
'tag_name' => $tag,
'name' => $name ?? $tag,
'body' => $body ?? '',
'target_commitish' => $target,
]);
$result = giteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
$releaseId = $result['data']['id'] ?? 'unknown';
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
} else {
fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n");
fwrite(STDERR, json_encode($result['data']) . "\n");
exit(1);
}
break;
case 'upload':
if (empty($files)) {
fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n");
exit(1);
}
$release = getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
fwrite(STDERR, "No release found for tag: {$tag}\n");
exit(1);
}
$releaseId = $release['id'];
// Get existing assets to avoid duplicates
$assetsResult = giteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath);
if (!file_exists($filePath)) {
fwrite(STDERR, "File not found: {$filePath}\n");
continue;
}
$fileName = basename($filePath);
// Delete existing asset with same name
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
giteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
}
// Upload
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = giteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n");
}
}
break;
case 'update-body':
$release = getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
fwrite(STDERR, "No release found for tag: {$tag}\n");
exit(1);
}
$releaseId = $release['id'];
$payload = json_encode(['body' => $body ?? '']);
$result = giteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n");
exit(1);
}
break;
case 'delete':
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
giteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted: {$tag} (id: {$existing['id']})\n";
} else {
echo "No release found for tag: {$tag}\n";
}
break;
default:
fwrite(STDERR, "Unknown action: {$action}\n");
fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n");
exit(1);
}
exit(0);
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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/updates_xml_build.php
* BRIEF: Generate Joomla updates.xml from extension manifest metadata
*
* Usage:
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output
*
* Options:
* --path Repository root (default: .)
* --version Version string (required)
* --stability One of: stable, rc, beta, alpha, development (default: stable)
* --sha SHA-256 hash of the ZIP package (optional)
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
* --org Organization (default: env GITEA_ORG)
* --repo Repository name (default: env GITEA_REPO)
* --output Output file path (default: updates.xml in --path)
* --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT
*/
declare(strict_types=1);
// -- Argument parsing ---------------------------------------------------------
$path = '.';
$version = null;
$stability = 'stable';
$sha = null;
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$outputFile = null;
$githubOutput = false;
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 === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
if ($arg === '--sha' && isset($argv[$i + 1])) $sha = $argv[$i + 1];
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
if ($arg === '--output' && isset($argv[$i + 1])) $outputFile = $argv[$i + 1];
if ($arg === '--github-output') $githubOutput = true;
}
if ($version === null) {
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
exit(1);
}
$root = realpath($path) ?: $path;
// -- Locate Joomla manifest ---------------------------------------------------
$manifest = null;
// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
foreach ($candidates as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
if ($manifest === null) {
$searchDirs = ["{$root}/src", "{$root}"];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break 2;
}
}
}
}
if ($manifest === null) {
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
exit(1);
}
// -- Parse extension metadata -------------------------------------------------
$xml = file_get_contents($manifest);
// Extract fields via regex (more portable than SimpleXML for malformed manifests)
$extName = '';
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) $extName = $m[1];
$extType = '';
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
$extElement = '';
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
if (empty($extElement)) {
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
if (in_array($fname, ['templatedetails', 'manifest'])) {
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
} else {
$extElement = $fname;
}
}
$extClient = '';
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1];
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
$targetPlatform = '';
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
if (empty($targetPlatform)) {
$targetPlatform = '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />';
}
$phpMinimum = '';
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1];
// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS)
if (preg_match('/^[A-Z_]+$/', $extName)) {
$iniFiles = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
$iniFiles[] = $file->getPathname();
}
}
foreach ($iniFiles as $ini) {
$content = file_get_contents($ini);
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
$extName = $m[1];
break;
}
}
}
// Fallbacks
if (empty($extName)) $extName = $repo ?: basename($root);
if (empty($extType)) $extType = 'component';
// -- Build type prefix --------------------------------------------------------
$typePrefix = '';
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;
}
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"ext_element={$extElement}",
"ext_name={$extName}",
"ext_type={$extType}",
"ext_folder={$extFolder}",
"type_prefix={$typePrefix}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
} else {
foreach ($lines as $line) echo "{$line}\n";
}
}
// -- Stability suffix map -----------------------------------------------------
$stabilitySuffixMap = [
'stable' => '',
'rc' => '-rc',
'beta' => '-beta',
'alpha' => '-alpha',
'development' => '-dev',
];
$stabilityTagMap = [
'stable' => 'stable',
'rc' => 'rc',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'dev',
];
// -- Build update entries -----------------------------------------------------
$releaseTag = $stabilityTagMap[$stability] ?? $stability;
// For the primary entry: apply suffix if not stable
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
$primaryVersion = $version . $primarySuffix;
$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip";
$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}";
// Build client tag
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " <client>{$extClient}</client>";
} elseif ($extType === 'module' || $extType === 'plugin') {
$clientTag = ' <client>site</client>';
}
// Build folder tag
$folderTag = '';
if (!empty($extFolder) && $extType === 'plugin') {
$folderTag = " <folder>{$extFolder}</folder>";
}
// PHP minimum tag
$phpTag = '';
if (!empty($phpMinimum)) {
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
}
// SHA tag
$shaTag = '';
if (!empty($sha)) {
$shaTag = " <sha256>{$sha}</sha256>";
}
/**
* Build a single <update> entry for a given stability tag
*/
function buildEntry(
string $tagName,
string $entryVersion,
string $entryDownloadUrl,
string $extName,
string $extElement,
string $extType,
string $clientTag,
string $folderTag,
string $infoUrl,
string $targetPlatform,
string $phpTag,
string $shaTag
): string {
$lines = [];
$lines[] = ' <update>';
$lines[] = " <name>{$extName}</name>";
$lines[] = " <description>{$extName} update</description>";
$lines[] = " <element>{$extElement}</element>";
$lines[] = " <type>{$extType}</type>";
$lines[] = " <version>{$entryVersion}</version>";
if (!empty($clientTag)) $lines[] = $clientTag;
if (!empty($folderTag)) $lines[] = $folderTag;
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
$lines[] = " <infourl title=\"{$extName}\">{$infoUrl}</infourl>";
$lines[] = ' <downloads>';
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$entryDownloadUrl}</downloadurl>";
$lines[] = ' </downloads>';
if (!empty($shaTag)) $lines[] = $shaTag;
$lines[] = " {$targetPlatform}";
if (!empty($phpTag)) $lines[] = $phpTag;
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
$lines[] = ' </update>';
return implode("\n", $lines);
}
// -- Determine which channels to write ----------------------------------------
// Stable cascades to all channels; pre-releases only write their level and below
// Each channel gets its own suffixed version:
// development -> 04.01.00-dev
// alpha -> 04.01.00-alpha
// beta -> 04.01.00-beta
// rc -> 04.01.00-rc
// stable -> 04.01.00
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
// Write entries for this stability and all below it
$entries = [];
for ($i = 0; $i <= $stabilityIndex; $i++) {
$channelName = $allChannels[$i];
$channelSuffix = $stabilitySuffixMap[$channelName] ?? '';
$channelVersion = $version . $channelSuffix;
$channelTag = $stabilityTagMap[$channelName] ?? $channelName;
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}";
$entries[] = buildEntry(
$channelName,
$channelVersion,
$channelDownloadUrl,
$extName,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$shaTag
);
}
// -- Write updates.xml --------------------------------------------------------
$year = date('Y');
$output = <<<XML
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: {$primaryVersion}
-->
<updates>
XML;
$output .= "\n" . implode("\n", $entries) . "\n</updates>\n";
$dest = $outputFile ?? "{$root}/updates.xml";
file_put_contents($dest, $output);
$channelCount = count($entries);
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
echo "Output: {$dest}\n";
exit(0);
+27 -1
View File
@@ -10,6 +10,13 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_set_platform.php
* BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)
*
* Usage:
* php version_set_platform.php --path . --version 04.01.00
* php version_set_platform.php --path . --version 04.01.00 --stability alpha
*
* When --stability is set to anything other than "stable", the suffix is
* appended to the version (e.g. 04.01.00-dev, 04.01.00-alpha, 04.01.00-rc).
*/
declare(strict_types=1);
@@ -17,10 +24,13 @@ declare(strict_types=1);
$path = '.';
$version = null;
$branch = null;
$stability = 'stable';
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 === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
}
// Auto-detect branch from git or GitHub env
@@ -32,10 +42,26 @@ if ($branch === null) {
}
if ($version === null) {
fwrite(STDERR, "Usage: version_set_platform.php --path . --version development\n");
fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n");
exit(1);
}
// Append stability suffix for non-stable releases
$stabilitySuffixMap = [
'stable' => '',
'development' => '-dev',
'dev' => '-dev',
'alpha' => '-alpha',
'beta' => '-beta',
'rc' => '-rc',
'release-candidate' => '-rc',
];
$suffix = $stabilitySuffixMap[$stability] ?? '';
if ($suffix !== '' && !str_ends_with($version, $suffix)) {
$version .= $suffix;
echo "Version with stability suffix: {$version}\n";
}
$root = realpath($path) ?: $path;
// Detect platform