#!/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_mirror.php * BRIEF: Mirror a Gitea release (with assets) to a GitHub repository */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class ReleaseMirrorCli extends CliFramework { protected function configure(): void { $this->setDescription('Mirror a Gitea release (with assets) to a GitHub repository'); $this->addArgument('--version', 'Version string (required)', ''); $this->addArgument('--tag', 'Release tag name (required)', ''); $this->addArgument('--token', 'Gitea API token', ''); $this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', ''); $this->addArgument('--gh-token', 'GitHub personal access token', ''); $this->addArgument('--gh-repo', 'GitHub org/repo (required)', ''); $this->addArgument('--branch', 'Target branch (default: main)', 'main'); } protected function run(): int { $version = $this->getArgument('--version'); $tag = $this->getArgument('--tag'); $token = $this->getArgument('--token'); $apiBase = $this->getArgument('--api-base'); $ghToken = $this->getArgument('--gh-token'); $ghRepo = $this->getArgument('--gh-repo'); $branch = $this->getArgument('--branch'); // Allow tokens from environment $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: '')); $ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: ''); if ($version === '' || $tag === '' || $token === '' || $apiBase === '' || $ghToken === '' || $ghRepo === '') { $this->log('ERROR', "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " . "--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]"); $this->log('ERROR', " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)"); $this->log('ERROR', " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)"); return 1; } // ── Step 1: Get Gitea release by tag ───────────────────────────────────────── echo "Fetching Gitea release: {$tag}\n"; $giteaRelease = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token); if (!$giteaRelease || empty($giteaRelease['id'])) { $this->log('ERROR', "No Gitea release found with tag: {$tag}"); return 1; } $giteaId = $giteaRelease['id']; $releaseName = $giteaRelease['name'] ?? "{$version}"; $releaseBody = $giteaRelease['body'] ?? ''; $assets = $giteaRelease['assets'] ?? []; echo " Name: {$releaseName}\n"; echo " Assets: " . count($assets) . " file(s)\n"; // ── Step 2: Check / create GitHub release ──────────────────────────────────── $ghApiBase = "https://api.github.com/repos/{$ghRepo}"; $ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}"; echo "Checking GitHub release: {$tag}\n"; $ghRelease = $this->githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken); if ($ghRelease && !empty($ghRelease['id'])) { // Update existing release title $ghReleaseId = $ghRelease['id']; echo " GitHub release exists (id: {$ghReleaseId}), updating title\n"; $patchPayload = json_encode([ 'name' => $releaseName, 'body' => $releaseBody, ]); $this->githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload); } else { // Create new release echo " Creating GitHub release\n"; $createPayload = json_encode([ 'tag_name' => $tag, 'target_commitish' => $branch, 'name' => $releaseName, 'body' => $releaseBody, 'draft' => false, 'prerelease' => ($tag !== 'stable'), ]); $ghRelease = $this->githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload); if (!$ghRelease || empty($ghRelease['id'])) { $this->log('ERROR', 'Failed to create GitHub release'); return 1; } $ghReleaseId = $ghRelease['id']; echo " Created GitHub release (id: {$ghReleaseId})\n"; } // ── Step 3: Download assets from Gitea ─────────────────────────────────────── $tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid(); @mkdir($tmpDir, 0755, true); $uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets"; foreach ($assets as $asset) { $name = $asset['name'] ?? ''; $downloadUrl = $asset['browser_download_url'] ?? ''; if ($name === '' || $downloadUrl === '') { continue; } $localPath = "{$tmpDir}/{$name}"; echo " Downloading: {$name}\n"; if (!$this->giteaDownload($downloadUrl, $token, $localPath)) { $this->log('ERROR', " Failed to download: {$name}"); continue; } // ── Step 4: Upload asset to GitHub ─────────────────────────────────────── echo " Uploading: {$name}\n"; $code = $this->githubUploadAsset($uploadUrl, $ghToken, $localPath, $name); $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; echo " {$status}\n"; } // ── Cleanup ────────────────────────────────────────────────────────────────── array_map('unlink', glob("{$tmpDir}/*") ?: []); @rmdir($tmpDir); // ── Summary ────────────────────────────────────────────────────────────────── echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n"; echo " Version: {$version}\n"; echo " Assets: " . count($assets) . " file(s)\n"; return 0; } private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$token}", 'Content-Type: application/json', ], CURLOPT_TIMEOUT => 30, CURLOPT_CUSTOMREQUEST => $method, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { return null; } return json_decode($response, true) ?: null; } private function giteaDownload(string $url, string $token, string $dest): bool { $ch = curl_init($url); $fp = fopen($dest, 'wb'); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 120, ]); curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); fclose($fp); return $httpCode >= 200 && $httpCode < 300; } private function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$token}", 'Accept: application/vnd.github+json', 'User-Agent: moko-platform', 'Content-Type: application/json', ], CURLOPT_TIMEOUT => 30, CURLOPT_CUSTOMREQUEST => $method, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { return null; } return json_decode($response, true) ?: null; } private function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int { $url = $uploadUrl . '?name=' . urlencode($name); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$token}", 'Accept: application/vnd.github+json', 'User-Agent: moko-platform', 'Content-Type: application/octet-stream', ], CURLOPT_POSTFIELDS => file_get_contents($filePath), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, ]); curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $httpCode; } } $app = new ReleaseMirrorCli(); exit($app->execute());