diff --git a/cli/release_cascade.php b/cli/release_cascade.php index 9218e80..6a866dc 100644 --- a/cli/release_cascade.php +++ b/cli/release_cascade.php @@ -6,12 +6,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: mokoplatform.CLI - * INGROUP: mokoplatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform + * DEFGROUP: mokocli.CLI + * INGROUP: mokocli + * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /cli/release_cascade.php - * VERSION: 09.29.01 - * BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent. + * VERSION: 10.00.00 + * BRIEF: Cascade release zip to all lower stability channels */ declare(strict_types=1); @@ -22,15 +22,320 @@ 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('DEPRECATED — cascade behavior removed'); + $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 { - $this->log('INFO', 'No-op (cascade behavior removed — each stream is independent)'); - return 0; + $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; } }