feat: release_mirror.php — mirror Gitea releases to GitHub
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 43s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 43s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
New CLI tool that mirrors a Gitea release (with assets) to a GitHub repository. Replaces the 40-line inline bash in auto-release.yml Step 9. Supports create/update, asset download+upload, and proper GitHub API headers (User-Agent, Accept). Closes #160 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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<string, mixed>|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<string, mixed>|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);
|
||||
Reference in New Issue
Block a user