#!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: moko-platform.CLI * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_verify.php * BRIEF: Verify a built release artifact — version, SHA256, disallowed files */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class ReleaseVerifyCli extends CliFramework { private int $pass = 0; private int $fail = 0; private int $warn = 0; private array $results = []; protected function configure(): void { $this->setDescription('Verify a built release artifact — version, SHA256, disallowed files'); $this->addArgument('--zip-path', 'Path to ZIP file (required)', ''); $this->addArgument('--version', 'Expected version string (required)', ''); $this->addArgument('--platform', 'joomla|dolibarr|generic', 'joomla'); $this->addArgument('--updates-xml', 'Path to updates.xml for SHA256 comparison', ''); $this->addArgument('--github-output', 'Export verify_pass, verify_fail to $GITHUB_OUTPUT', false); $this->addArgument('--output-summary', 'Write markdown table to $GITHUB_STEP_SUMMARY', false); } protected function run(): int { $zipPath = $this->getArgument('--zip-path'); $version = $this->getArgument('--version'); $platform = $this->getArgument('--platform'); $updatesXml = $this->getArgument('--updates-xml'); $githubOutput = $this->getArgument('--github-output'); $outputSummary = $this->getArgument('--output-summary'); if ($zipPath === '' || $version === '') { $this->log('ERROR', 'Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]'); return 1; } // 1. ZIP exists and is readable if (!file_exists($zipPath) || !is_readable($zipPath)) { $this->addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}"); } else { $this->addResult('ZIP exists', 'PASS', basename($zipPath)); // 2. Extract ZIP $tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid(); mkdir($tmpDir, 0755, true); $zip = new \ZipArchive(); if ($zip->open($zipPath) !== true) { $this->addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file'); } else { $zip->extractTo($tmpDir); $zip->close(); $this->addResult('ZIP extract', 'PASS', 'Extracted successfully'); // 3. Manifest version check (Joomla) if ($platform === 'joomla') { $manifest = null; foreach (glob("{$tmpDir}/*.xml") as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, '([^<]+)<\/version>/', file_get_contents($manifest), $m)) { $manifestVer = trim($m[1]); if ($manifestVer === $version) { $this->addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release"); } else { $this->addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`"); } } else { $this->addResult('Manifest version', 'WARN', 'No tag in manifest'); } } else { $this->addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP'); } } // 4. SHA256 vs updates.xml $zipSha = hash_file('sha256', $zipPath); if ($updatesXml !== '' && file_exists($updatesXml)) { $uxContent = file_get_contents($updatesXml); if (preg_match('/([^<]+)<\/sha256>/', $uxContent, $m)) { $expectedSha = trim($m[1]); if ($zipSha === $expectedSha) { $this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`'); } else { $this->addResult( 'SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`" ); } } else { $this->addResult('SHA256 vs updates.xml', 'WARN', 'No in updates.xml'); } } // 5. Disallowed files $disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env']; $found = []; $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS)); foreach ($rit as $file) { $name = $file->getFilename(); if (in_array($name, $disallowed, true)) { $found[] = $name; } } if (count($found) > 0) { $this->addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found))); } else { $this->addResult('Disallowed files', 'PASS', 'None found'); } // 6. Non-vendor .min files $minCount = 0; $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS)); foreach ($rit as $file) { $rel = str_replace($tmpDir . '/', '', $file->getPathname()); if (strpos($rel, 'vendor/') !== false) { continue; } if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) { $minCount++; } } if ($minCount > 0) { $this->addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime"); } else { $this->addResult('Non-vendor .min files', 'PASS', 'None shipped'); } // Clean up $rit = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($rit as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } rmdir($tmpDir); } } // Output $table = "| Check | Result | Details |\n|-------|--------|--------|\n"; foreach ($this->results as $r) { $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; } $table .= "\n**Verification: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n"; echo $table; if ($outputSummary) { $summaryFile = getenv('GITHUB_STEP_SUMMARY'); if ($summaryFile) { file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND); } } if ($githubOutput) { $outputFile = getenv('GITHUB_OUTPUT'); if ($outputFile) { file_put_contents($outputFile, "verify_pass={$this->pass}\nverify_fail={$this->fail}\nverify_warn={$this->warn}\n", FILE_APPEND); } } return $this->fail > 0 ? 1 : 0; } private function addResult(string $check, string $status, string $details): void { $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details]; if ($status === 'PASS') { $this->pass++; } elseif ($status === 'FAIL') { $this->fail++; } elseif ($status === 'WARN') { $this->warn++; } } } $app = new ReleaseVerifyCli(); exit($app->execute());