#!/usr/bin/env php * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Automation * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/push_mokostandards_xml.php * BRIEF: Push XML manifests to all governed repositories * * Push XML .mokostandards manifest to all governed repositories. * * Uses git SSH to bypass the Gitea reverse-proxy WAF that blocks * API requests to paths containing ".mokogitea". * * Usage: * php automation/push_mokostandards_xml.php [--dry-run] [--repo NAME] [--force] */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoEnterprise\MokoStandardsParser; // ── Configuration ──────────────────────────────────────────────────────── $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; $sshBase = 'ssh://gitea@git.mokoconsulting.tech:2222'; // ── CLI args ───────────────────────────────────────────────────────────── $dryRun = in_array('--dry-run', $argv, true); $force = in_array('--force', $argv, true); $repoFilter = null; $skipRepos = []; foreach ($argv as $i => $arg) { if ($arg === '--repo' && isset($argv[$i + 1])) { $repoFilter = $argv[$i + 1]; } if ($arg === '--skip' && isset($argv[$i + 1])) { $skipRepos = array_map('trim', explode(',', $argv[$i + 1])); } } $parser = new MokoStandardsParser(); $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid(); // ── Platform detection heuristics (mirrors RepositorySynchronizer) ─────── $CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; function detectPlatform(array $repo): string { global $CRM_PLATFORM_REPOS; $name = $repo['name'] ?? ''; $nameLower = strtolower($name); $description = strtolower($repo['description'] ?? ''); $topics = $repo['topics'] ?? []; if (in_array($name, $CRM_PLATFORM_REPOS, true)) { return 'crm-platform'; } if (in_array('dolibarr-platform', $topics)) { return 'crm-platform'; } if (in_array('joomla-template', $topics)) { return 'joomla-template'; } if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { return 'waas-component'; } if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { return 'crm-module'; } if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { return 'joomla-template'; } if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { return 'waas-component'; } if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { return 'crm-module'; } if (str_contains($description, 'joomla template')) { return 'joomla-template'; } if (str_contains($description, 'joomla') || str_contains($description, 'component')) { return 'waas-component'; } if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { return 'crm-module'; } if (str_contains($nameLower, 'standard')) { return 'standards-repository'; } return 'default-repository'; } /** * Safe shell execution — uses proc_open with explicit arguments to avoid injection. * @return array{int, string} */ function safeExec(string $command, string $cwd = '.'): array { $proc = proc_open( $command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd ); if (!is_resource($proc)) { return [1, "proc_open failed for: {$command}"]; } $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($proc); return [$code, trim($stdout . "\n" . $stderr)]; } /** Recursively remove a directory (cross-platform). */ function rmTree(string $dir): void { if (!is_dir($dir)) { return; } $it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { if ($file->isDir()) { @rmdir($file->getPathname()); } else { // Clear read-only flag (git objects on Windows) @chmod($file->getPathname(), 0777); @unlink($file->getPathname()); } } @rmdir($dir); } /** * Run a git command safely in a given working directory. * @return array{int, string} */ function gitCmd(string $workDir, string ...$args): array { $cmd = 'git'; foreach ($args as $a) { $cmd .= ' ' . escapeshellarg($a); } return safeExec($cmd, $workDir); } // ── Fetch all repos via API ────────────────────────────────────────────── function fetchRepos(string $url, string $org, string $token): array { $repos = []; $page = 1; do { $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code !== 200) { fprintf(STDERR, "API error (HTTP %d) fetching repos page %d\n", $code, $page); break; } $batch = json_decode($body, true); if (empty($batch)) { break; } $repos = array_merge($repos, $batch); $page++; } while (count($batch) >= 50); return $repos; } // ── Main ───────────────────────────────────────────────────────────────── echo "=== MokoStandards XML Manifest Push ===\n"; echo "Org: {$giteaOrg}\n"; echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n"; if ($repoFilter) { echo "Filter: {$repoFilter}\n"; } echo "\n"; if (empty($token)) { fprintf(STDERR, "ERROR: GA_TOKEN or GH_TOKEN environment variable required\n"); exit(1); } $repos = fetchRepos($giteaUrl, $giteaOrg, $token); echo "Found " . count($repos) . " repositories\n\n"; $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0]; foreach ($repos as $repo) { $name = $repo['name']; if ($repoFilter && $name !== $repoFilter) { continue; } if (in_array($name, $skipRepos, true)) { echo " SKIP {$name} (excluded)\n"; $stats['skipped']++; continue; } if ($repo['archived'] ?? false) { echo " SKIP {$name} (archived)\n"; $stats['skipped']++; continue; } $platform = detectPlatform($repo); $defaultBranch = $repo['default_branch'] ?? 'main'; // Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; // Embed token in HTTPS URL for push auth $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); echo " {$name} [{$platform}] ... "; // Generate XML manifest $xmlContent = $parser->generate([ 'name' => $name, 'org' => $giteaOrg, 'platform' => $platform, 'standards_version' => '04.07.00', 'description' => $repo['description'] ?? '', 'license' => 'GPL-3.0-or-later', 'topics' => $repo['topics'] ?? [], 'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform), 'package_type' => MokoStandardsParser::platformPackageType($platform), 'last_synced' => date('c'), ]); if ($dryRun) { echo "WOULD WRITE ({$platform})\n"; $stats['created']++; continue; } // Clone shallow via HTTPS (token-authed) $workDir = "{$tmpBase}/{$name}"; @mkdir($workDir, 0755, true); [$ret, $out] = safeExec( 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) ); if ($ret !== 0) { echo "FAIL (clone)\n"; fprintf(STDERR, " %s\n", $out); $stats['failed']++; continue; } // Check if already XML and up-to-date $manifestPath = "{$workDir}/.mokogitea/.mokostandards"; $existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); if ($existingPlatform === $platform) { echo "SKIP (already XML)\n"; $stats['skipped']++; rmTree($workDir); continue; } } // Write manifest @mkdir("{$workDir}/.gitea", 0755, true); file_put_contents($manifestPath, $xmlContent); // Delete legacy files if present $legacyDeleted = []; foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) { $legacyPath = "{$workDir}/{$legacy}"; if (file_exists($legacyPath)) { unlink($legacyPath); $legacyDeleted[] = $legacy; } } // Commit $isNew = !$existingIsXml; $commitMsg = $isNew ? 'chore: add XML .mokostandards manifest' : 'chore: update .mokostandards to XML format'; if (!empty($legacyDeleted)) { $commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); } gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); gitCmd($workDir, 'add', '.mokogitea/.mokostandards'); foreach ($legacyDeleted as $lf) { gitCmd($workDir, 'add', $lf); } [$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg); if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { echo "SKIP (no changes)\n"; $stats['skipped']++; rmTree($workDir); continue; } if ($commitRet !== 0) { echo "FAIL (commit)\n"; fprintf(STDERR, " %s\n", $commitOut); $stats['failed']++; rmTree($workDir); continue; } [$pushRet, $pushOut] = gitCmd($workDir, 'push', 'origin', $defaultBranch); if ($pushRet !== 0) { echo "FAIL (push)\n"; fprintf(STDERR, " %s\n", $pushOut); $stats['failed']++; } else { $action = $isNew ? 'CREATED' : 'UPDATED'; echo "{$action}\n"; $stats[$isNew ? 'created' : 'updated']++; } // Cleanup rmTree($workDir); } // Cleanup tmp base @rmdir($tmpBase); echo "\n=== Summary ===\n"; echo "Created: {$stats['created']}\n"; echo "Updated: {$stats['updated']}\n"; echo "Skipped: {$stats['skipped']}\n"; echo "Failed: {$stats['failed']}\n";