#!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: mokocli.CLI * INGROUP: mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /cli/release_cascade.php * VERSION: 10.00.00 * BRIEF: Cascade release zip to all lower stability channels */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class ReleaseCascadeCli extends CliFramework { /** Channel hierarchy: highest stability first. */ private const CHANNELS = ['stable', 'release-candidate', 'beta', 'alpha', 'development']; /** Map stability input names to canonical tag names. */ private const TAG_MAP = [ 'stable' => 'stable', 'release-candidate' => 'release-candidate', 'rc' => 'release-candidate', 'beta' => 'beta', 'alpha' => 'alpha', 'development' => 'development', 'dev' => 'development', ]; protected function configure(): void { $this->setDescription('Cascade release zip to all lower stability channels'); $this->addArgument('--stability', 'Source stability channel (required)', ''); $this->addArgument('--token', 'Gitea API token (required)', ''); $this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', ''); } protected function run(): int { $stability = strtolower($this->getArgument('--stability')); $token = $this->getArgument('--token'); $apiBase = rtrim($this->getArgument('--api-base'), '/'); if ($token === '') { $envToken = getenv('MOKOGITEA_TOKEN'); if ($envToken === false || $envToken === '') { $envToken = getenv('GITEA_TOKEN'); } if ($envToken !== false && $envToken !== '') { $token = $envToken; } } if ($stability === '' || $token === '' || $apiBase === '') { $this->log('ERROR', 'Usage: release_cascade.php --stability CHANNEL --token TOKEN --api-base URL'); return 1; } $sourceTag = self::TAG_MAP[$stability] ?? null; if ($sourceTag === null) { $this->log('ERROR', "Unknown stability: {$stability}"); return 1; } // Find lower channels to cascade to $lowerChannels = $this->getLowerChannels($sourceTag); if (count($lowerChannels) === 0) { $this->log('INFO', "No lower channels for '{$stability}' — nothing to cascade."); return 0; } $this->log('INFO', "Cascading from '{$sourceTag}' to: " . implode(', ', $lowerChannels)); if ($this->dryRun) { $this->log('INFO', '[DRY RUN] No changes will be made.'); } // 1. Get source release $sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$sourceTag}", $token); if ($sourceRelease === null) { $this->log('WARN', "No release found at tag '{$sourceTag}' — nothing to cascade."); return 0; } $sourceVersion = $sourceRelease['name'] ?? $sourceTag; $sourceBody = $sourceRelease['body'] ?? ''; $sourceAssets = $sourceRelease['assets'] ?? []; // Find zip assets (exclude .sha256 sidecars) $zipAssets = array_filter($sourceAssets, function (array $asset): bool { $name = strtolower($asset['name'] ?? ''); return str_ends_with($name, '.zip') && !str_ends_with($name, '.sha256'); }); // Also grab sha256 sidecars $sha256Assets = array_filter($sourceAssets, function (array $asset): bool { return str_ends_with(strtolower($asset['name'] ?? ''), '.zip.sha256'); }); if (count($zipAssets) === 0) { $this->log('WARN', "Source release '{$sourceTag}' has no zip assets — nothing to cascade."); return 0; } $this->log('INFO', "Source: {$sourceVersion} — " . count($zipAssets) . " zip(s)"); echo "\n"; // 2. Download source assets to temp files $downloads = []; foreach (array_merge($zipAssets, $sha256Assets) as $asset) { $url = $asset['browser_download_url'] ?? ''; if ($url === '') { continue; } $tmpFile = tempnam(sys_get_temp_dir(), 'cascade_'); if ($this->downloadFile($url, $token, $tmpFile)) { $downloads[] = ['name' => $asset['name'], 'path' => $tmpFile]; $this->log('INFO', "Downloaded: {$asset['name']}"); } else { $this->log('ERROR', "Failed to download: {$asset['name']}"); } } if (count($downloads) === 0) { $this->log('ERROR', 'Could not download any source assets.'); return 1; } // 3. Cascade to each lower channel $errors = 0; foreach ($lowerChannels as $targetTag) { echo "\n"; $result = $this->cascadeToChannel( $apiBase, $token, $targetTag, $sourceVersion, $sourceBody, $downloads ); if (!$result) { $errors++; } } // 4. Cleanup temp files foreach ($downloads as $dl) { @unlink($dl['path']); } echo "\n"; $this->log('INFO', "Cascade complete. " . (count($lowerChannels) - $errors) . "/" . count($lowerChannels) . " channels updated."); return $errors > 0 ? 1 : 0; } /** * Cascade assets to a single target channel. */ private function cascadeToChannel( string $apiBase, string $token, string $targetTag, string $sourceVersion, string $sourceBody, array $downloads ): bool { $this->log('INFO', "→ {$targetTag}"); if ($this->dryRun) { $this->log('INFO', " [DRY RUN] Would cascade to {$targetTag}"); return true; } // Find existing release at target tag $existing = $this->giteaApi("{$apiBase}/releases/tags/{$targetTag}", $token); if ($existing !== null && !empty($existing['id'])) { $releaseId = (int) $existing['id']; // Delete existing assets $existingAssets = $existing['assets'] ?? []; foreach ($existingAssets as $asset) { $assetId = $asset['id'] ?? 0; if ($assetId > 0) { $this->giteaApi( "{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE' ); } } // Update release metadata $updatePayload = json_encode([ 'name' => $sourceVersion, 'body' => $sourceBody, ]); $this->giteaApi( "{$apiBase}/releases/{$releaseId}", $token, 'PATCH', $updatePayload ); $this->log('INFO', " Updated release metadata (id: {$releaseId})"); } else { // Create new release at target tag // Use the source release's target commitish so the tag points to the same commit $createPayload = json_encode([ 'tag_name' => $targetTag, 'target_commitish' => 'main', 'name' => $sourceVersion, 'body' => $sourceBody, 'prerelease' => ($targetTag !== 'stable'), ]); $newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $createPayload); if ($newRelease === null || empty($newRelease['id'])) { $this->log('ERROR', " Failed to create release at tag '{$targetTag}'"); return false; } $releaseId = (int) $newRelease['id']; $this->log('INFO', " Created release (id: {$releaseId})"); } // Upload assets foreach ($downloads as $dl) { $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . rawurlencode($dl['name']); $success = $this->uploadAsset($uploadUrl, $token, $dl['path'], $dl['name']); if ($success) { $this->log('INFO', " Uploaded: {$dl['name']}"); } else { $this->log('ERROR', " Failed to upload: {$dl['name']}"); } } return true; } /** * Get all channels below the given source channel. */ private function getLowerChannels(string $sourceTag): array { $idx = array_search($sourceTag, self::CHANNELS, true); if ($idx === false) { return []; } return array_slice(self::CHANNELS, $idx + 1); } /** * Download a file via HTTP. */ private function downloadFile(string $url, string $token, string $destPath): bool { $ch = curl_init($url); if ($ch === false) { return false; } $fp = fopen($destPath, 'wb'); if ($fp === false) { return false; } curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => true, CURLOPT_FILE => $fp, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 120, ]); curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); fclose($fp); return $code >= 200 && $code < 300; } /** * Upload a file as a release asset via multipart form. */ private function uploadAsset(string $url, string $token, string $filePath, string $fileName): bool { $ch = curl_init($url); if ($ch === false) { return false; } $cfile = new CURLFile($filePath, 'application/octet-stream', $fileName); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => ['attachment' => $cfile], CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 120, ]); $response = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $code >= 200 && $code < 300; } /** * Make an HTTP request to the Gitea API. */ private function giteaApi( string $url, string $token, string $method = 'GET', ?string $body = null ): ?array { $ch = curl_init($url); if ($ch === false) { return null; } 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 = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) { return null; } $decoded = json_decode($response, true); return is_array($decoded) ? $decoded : null; } } $app = new ReleaseCascadeCli(); exit($app->execute());