#!/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_promote.php * BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets) */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class ReleasePromoteCli extends CliFramework { protected function configure(): void { $this->setDescription('Promote a Gitea release from one channel to another'); $this->addArgument('--from', 'Source channel (or "auto")', ''); $this->addArgument('--to', 'Target channel (required)', ''); $this->addArgument('--token', 'Gitea API token', ''); $this->addArgument('--api-base', 'Gitea API base URL for the repo', ''); $this->addArgument('--path', 'Repository root for type prefix detection', '.'); $this->addArgument('--branch', 'Target branch', 'main'); } protected function run(): int { $from = $this->getArgument('--from') ?: null; $to = $this->getArgument('--to') ?: null; $token = $this->getArgument('--token') ?: null; $apiBase = $this->getArgument('--api-base') ?: null; $path = $this->getArgument('--path'); $branch = $this->getArgument('--branch'); $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); if ($to === null || $token === null || $apiBase === null) { $this->log('ERROR', "Usage: release_promote.php --from --to --token TOKEN --api-base URL [--path .]"); $this->log('ERROR', " --from auto: checks beta > alpha > development"); return 1; } // ── Suffix maps ────────────────────────────────────────────────────────────── $suffixMap = [ 'development' => '-dev', 'alpha' => '-alpha', 'beta' => '-beta', 'release-candidate' => '-rc', 'stable' => '', ]; // ── Channel hierarchy (highest first) ──────────────────────────────────────── $channelOrder = ['beta', 'alpha', 'development']; // ── Resolve --from auto ────────────────────────────────────────────────────── if ($from === 'auto') { foreach ($channelOrder as $candidate) { $data = $this->giteaApi("{$apiBase}/releases/tags/{$candidate}", $token); if ($data && !empty($data['id'])) { $from = $candidate; echo "Auto-detected source channel: {$from}\n"; break; } } if ($from === 'auto') { echo "No pre-release found to promote\n"; return 0; } } // ── Find source release ────────────────────────────────────────────────────── $sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$from}", $token); if (!$sourceRelease || empty($sourceRelease['id'])) { $this->log('ERROR', "No release found with tag: {$from}"); return 1; } $sourceId = $sourceRelease['id']; $sourceName = $sourceRelease['name'] ?? ''; $sourceBody = $sourceRelease['body'] ?? ''; echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n"; // ── Get source assets ──────────────────────────────────────────────────────── $assets = $this->giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: []; echo "Assets: " . count($assets) . " file(s)\n"; // ── Download assets to temp ────────────────────────────────────────────────── $tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid(); @mkdir($tmpDir, 0755, true); foreach ($assets as $asset) { $name = $asset['name']; $downloadUrl = $asset['browser_download_url']; echo " Downloading: {$name}\n"; $this->giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}"); } // ── Detect type prefix for stable promotion ────────────────────────────────── $typePrefix = ''; if ($to === 'stable') { $root = realpath($path) ?: $path; $manifestFiles = array_merge( glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: [] ); foreach ($manifestFiles as $xmlFile) { $xmlContent = file_get_contents($xmlFile); if (strpos($xmlContent, ' $oldName, 'new' => $newName]; if ($oldName !== $newName) { echo " Rename: {$oldName} → {$newName}\n"; } } // ── Delete source release + tag ────────────────────────────────────────────── $this->giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE'); $this->giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE'); echo "Deleted source: {$from} release + tag\n"; // ── Delete existing target release + tag (if any) ──────────────────────────── $existingTarget = $this->giteaApi("{$apiBase}/releases/tags/{$to}", $token); if ($existingTarget && !empty($existingTarget['id'])) { $this->giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE'); $this->giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE'); echo "Deleted existing target: {$to} release + tag\n"; } // ── Create target release ──────────────────────────────────────────────────── $isPrerelease = ($to !== 'stable'); $newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName); if ($newName === $sourceName) { $newName = str_ireplace($from, $to, $sourceName); } $newBody = str_ireplace($from, $to, $sourceBody); $payload = json_encode([ 'tag_name' => $to, 'target_commitish' => $branch, 'name' => $newName, 'body' => $newBody, 'prerelease' => $isPrerelease, ]); $newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload); if (!$newRelease || empty($newRelease['id'])) { $this->log('ERROR', "Failed to create {$to} release"); return 1; } $newId = $newRelease['id']; echo "Created: {$to} release (id: {$newId})\n"; // ── Upload renamed assets ──────────────────────────────────────────────────── foreach ($renamedAssets as $entry) { $localFile = "{$tmpDir}/{$entry['old']}"; if (!file_exists($localFile)) { continue; } $uploadName = urlencode($entry['new']); $url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}"; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$token}", 'Content-Type: application/octet-stream', ], CURLOPT_POSTFIELDS => file_get_contents($localFile), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, ]); curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; echo " Upload: {$entry['new']} — {$status}\n"; } // ── Cleanup temp ───────────────────────────────────────────────────────────── array_map('unlink', glob("{$tmpDir}/*") ?: []); @rmdir($tmpDir); echo "Promoted: {$from} → {$to}\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; } } $app = new ReleasePromoteCli(); exit($app->execute());