Files
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

217 lines
7.9 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.Templates.Scripts.Release
* INGROUP: MokoStandards.Templates
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /templates/scripts/release/package_joomla.php
* BRIEF: Build a distributable ZIP package for a Joomla component
* NOTE: Deployed to bin/build_package.php in governed WaaS component repos.
* Run: php bin/build_package.php [--version X.Y.Z] [--dry-run] [--output DIR]
*/
declare(strict_types=1);
// Deployed to bin/build_package.php in org repos — vendor/ is one level up (repo root)
require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\CliFramework;
/**
* Builds a release-ready ZIP package from a Joomla component repository.
*
* What it does:
* 1. Reads VERSION from composer.json (or --version override)
* 2. Resolves the component name from the XML manifest
* 3. Copies tracked files into a temp staging directory, excluding dev artefacts
* 4. Writes <ComponentName>-<version>.zip into the output directory
* 5. Generates SHA-256 and MD5 checksums alongside the ZIP
*/
class PackageJoomla extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Build a distributable ZIP package for a Joomla component');
$this->addArgument('--path', 'Repository root to package', '.');
$this->addArgument('--version', 'Override version (default: composer.json)', '');
$this->addArgument('--output', 'Output directory for the ZIP', 'build');
$this->addArgument('--dry-run', 'Preview without writing files', false);
}
protected function run(): int
{
$path = rtrim((string) $this->getArgument('--path'), '/\\');
$version = (string) $this->getArgument('--version');
$outDir = $path . '/' . ltrim((string) $this->getArgument('--output'), '/\\');
$dryRun = (bool) $this->getArgument('--dry-run');
if (!is_dir($path)) {
$this->error("Repository path does not exist: {$path}");
}
// ── 1. Resolve version ────────────────────────────────────────────
if ($version === '') {
$composerFile = $path . '/composer.json';
if (!file_exists($composerFile)) {
$this->error('Cannot determine version: composer.json not found and --version not set.');
}
$composer = json_decode((string) file_get_contents($composerFile), true);
$version = $composer['version'] ?? '';
if ($version === '') {
$this->error('composer.json has no "version" field. Pass --version explicitly.');
}
}
$this->log('INFO', "Version: {$version}");
// ── 2. Resolve component name ─────────────────────────────────────
$componentName = $this->resolveComponentName($path);
$this->log('INFO', "Component: {$componentName}");
// ── 3. Determine output paths ─────────────────────────────────────
$zipName = "{$componentName}-{$version}.zip";
$zipPath = "{$outDir}/{$zipName}";
if ($dryRun) {
$this->log('INFO', "[DRY-RUN] Would create: {$zipPath}");
$this->log('INFO', '[DRY-RUN] No files written.');
return 0;
}
// ── 4. Stage files ────────────────────────────────────────────────
if (!is_dir($outDir) && !mkdir($outDir, 0755, true)) {
$this->error("Cannot create output directory: {$outDir}");
}
$stagingDir = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
mkdir($stagingDir, 0755, true);
$this->section('Staging files');
$exclude = [
'build', 'tests', '.git', '.github', 'vendor',
'node_modules', 'composer.lock', 'phpunit.xml',
'phpcs.xml', 'phpstan.neon', 'psalm.xml',
'package.json', 'package-lock.json', '.env',
];
$copied = $this->copyTree($path, $stagingDir, $exclude);
$this->log('INFO', "Staged {$copied} files");
// ── 5. Create ZIP ─────────────────────────────────────────────────
$this->section('Creating package');
if (!class_exists('ZipArchive')) {
$this->error('PHP ZipArchive extension is required but not loaded.');
}
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->error("Cannot create ZIP: {$zipPath}");
}
$this->addDirectoryToZip($zip, $stagingDir, '');
$zip->close();
// Clean up staging dir
$this->removeTree($stagingDir);
$this->log('INFO', "Created: {$zipPath}");
// ── 6. Checksums ──────────────────────────────────────────────────
file_put_contents("{$zipPath}.sha256", hash_file('sha256', $zipPath) . " {$zipName}\n");
file_put_contents("{$zipPath}.md5", hash_file('md5', $zipPath) . " {$zipName}\n");
$this->log('INFO', "Checksums written: {$zipName}.sha256 / .md5");
$this->log('INFO', 'Package complete.');
return 0;
}
// ── Helpers ───────────────────────────────────────────────────────────
private function resolveComponentName(string $path): string
{
// Look for XML manifest at root or in administrator/components/com_*/
$candidates = array_merge(
glob($path . '/*.xml') ?: [],
glob($path . '/administrator/components/com_*/*.xml') ?: []
);
foreach ($candidates as $xml) {
$content = (string) file_get_contents($xml);
if (preg_match('/<extension[^>]+type=["\']component["\']/', $content)) {
if (preg_match('/<name>([^<]+)<\/name>/', $content, $m)) {
return preg_replace('/[^A-Za-z0-9_-]/', '', $m[1]);
}
}
}
// Fall back to repository directory name
return basename($path);
}
private function copyTree(string $src, string $dst, array $exclude): int
{
$copied = 0;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
$rel = substr($item->getPathname(), strlen($src) + 1);
$topSegment = explode(DIRECTORY_SEPARATOR, $rel)[0];
if (in_array($topSegment, $exclude, true)) {
continue;
}
$target = $dst . DIRECTORY_SEPARATOR . $rel;
if ($item->isDir()) {
if (!is_dir($target)) {
mkdir($target, 0755, true);
}
} else {
copy($item->getPathname(), $target);
$copied++;
}
}
return $copied;
}
private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix): void
{
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
$rel = substr($item->getPathname(), strlen($dir) + 1);
$entry = $prefix !== '' ? $prefix . '/' . $rel : $rel;
$entry = str_replace('\\', '/', $entry);
if ($item->isDir()) {
$zip->addEmptyDir($entry);
} else {
$zip->addFile($item->getPathname(), $entry);
}
}
}
private function removeTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
}
$script = new PackageJoomla('build_package', 'Build a distributable ZIP package for a Joomla component');
exit($script->execute());