diff --git a/bin/moko b/bin/moko index 34aea95..4a4c33b 100644 --- a/bin/moko +++ b/bin/moko @@ -129,6 +129,7 @@ const COMMAND_MAP = [ 'release:promote' => 'cli/release_promote.php', 'release:create' => 'cli/release_create.php', 'release:manage' => 'cli/release_manage.php', + 'release:mirror' => 'cli/release_mirror.php', 'release:package' => 'cli/release_package.php', // Version management diff --git a/cli/release_mirror.php b/cli/release_mirror.php new file mode 100644 index 0000000..459243b --- /dev/null +++ b/cli/release_mirror.php @@ -0,0 +1,300 @@ +#!/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 + * + * Usage: + * php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \ + * --gh-token GH_TOKEN --gh-repo MokoConsulting/MokoWaaS + * + * Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release. + * If the GitHub release already exists at the same tag, its title is updated via PATCH. + * All assets from the Gitea release are downloaded and uploaded to the GitHub release. + */ + +declare(strict_types=1); + +// ── Argument parsing ───────────────────────────────────────────────────────── + +$version = null; +$tag = null; +$token = null; +$apiBase = null; +$ghToken = null; +$ghRepo = null; +$branch = 'main'; + +foreach ($argv as $i => $arg) { + if ($arg === '--version' && isset($argv[$i + 1])) { + $version = $argv[$i + 1]; + } + if ($arg === '--tag' && isset($argv[$i + 1])) { + $tag = $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]; + } + if ($arg === '--gh-token' && isset($argv[$i + 1])) { + $ghToken = $argv[$i + 1]; + } + if ($arg === '--gh-repo' && isset($argv[$i + 1])) { + $ghRepo = $argv[$i + 1]; + } + if ($arg === '--branch' && isset($argv[$i + 1])) { + $branch = $argv[$i + 1]; + } +} + +// Allow tokens from environment +$token = $token ?: (getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); +$ghToken = $ghToken ?: (getenv('GH_TOKEN') ?: null); + +if ( + $version === null || $tag === null || $token === null || $apiBase === null + || $ghToken === null || $ghRepo === null +) { + fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " . + "--api-base URL --gh-token GH_TOKEN --gh-repo org/repo [--branch main]\n"); + fwrite(STDERR, " --token: Gitea token (or GA_TOKEN / GITEA_TOKEN env)\n"); + fwrite(STDERR, " --gh-token: GitHub token (or GH_TOKEN env)\n"); + exit(1); +} + +// ── Helper: Gitea API request ──────────────────────────────────────────────── + +/** + * Send a request to the Gitea API. + * + * @param string $url Full Gitea API URL + * @param string $token Gitea API token + * @param string $method HTTP method (GET, POST, PATCH, DELETE) + * @param string|null $body JSON request body or null + * + * @return array|null Decoded response or null on failure + */ +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; +} + +/** + * Download a file from Gitea to a local path. + * + * @param string $url Download URL + * @param string $token Gitea API token + * @param string $dest Local destination path + * + * @return bool True on success + */ +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; +} + +/** + * Send a request to the GitHub API. + * + * @param string $url Full GitHub API URL + * @param string $token GitHub personal access token + * @param string $method HTTP method (GET, POST, PATCH, DELETE) + * @param string|null $body JSON request body or null + * + * @return array|null Decoded response or null on failure + */ +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; +} + +/** + * Upload a binary asset to a GitHub release. + * + * @param string $uploadUrl GitHub upload URL (uploads.github.com) + * @param string $token GitHub personal access token + * @param string $filePath Local file path to upload + * @param string $name Asset filename for GitHub + * + * @return int HTTP status code + */ +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; +} + +// ── Step 1: Get Gitea release by tag ───────────────────────────────────────── + +echo "Fetching Gitea release: {$tag}\n"; +$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token); +if (!$giteaRelease || empty($giteaRelease['id'])) { + fwrite(STDERR, "No Gitea release found with tag: {$tag}\n"); + exit(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 = 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, + ]); + 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 = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload); + if (!$ghRelease || empty($ghRelease['id'])) { + fwrite(STDERR, "Failed to create GitHub release\n"); + exit(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 (!giteaDownload($downloadUrl, $token, $localPath)) { + fwrite(STDERR, " Failed to download: {$name}\n"); + continue; + } + + // ── Step 4: Upload asset to GitHub ─────────────────────────────────────── + echo " Uploading: {$name}\n"; + $code = 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"; +exit(0);