Public Access
84259c6636
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 48s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokocli CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
When a Joomla package has a sub-package that is a git submodule with an empty or missing source directory (e.g. failed CI checkout), the packager now falls back to downloading the latest stable release ZIP from the submodule's Gitea remote. Also supports pre-staged ZIPs in the output directory, allowing manual or workflow-based pre-population of sub-package archives. Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
680 lines
26 KiB
PHP
680 lines
26 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* 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_package.php
|
|
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
|
|
|
use MokoCli\{CliFramework, SourceResolver};
|
|
|
|
class ReleasePackageCli extends CliFramework
|
|
{
|
|
/** @var array<int, string> */
|
|
private array $excludePatterns = [
|
|
'sftp-config*',
|
|
'.ftpignore',
|
|
'*.ppk',
|
|
'*.pem',
|
|
'*.key',
|
|
'.env*',
|
|
'*.local',
|
|
'.build-trigger',
|
|
];
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release');
|
|
$this->addArgument('--path', 'Repository root path', '.');
|
|
$this->addArgument('--version', 'Release version', '');
|
|
$this->addArgument('--tag', 'Release tag name', '');
|
|
$this->addArgument('--token', 'Gitea API token', '');
|
|
$this->addArgument('--api-base', 'Gitea API base URL', '');
|
|
$this->addArgument('--repo', 'Repo name for element detection fallback', '');
|
|
$this->addArgument('--output', 'Output directory for built packages', '');
|
|
}
|
|
|
|
protected function run(): int
|
|
{
|
|
$path = $this->getArgument('--path');
|
|
$version = $this->getArgument('--version');
|
|
$tag = $this->getArgument('--tag');
|
|
$token = $this->getArgument('--token');
|
|
$apiBase = $this->getArgument('--api-base');
|
|
$repoName = $this->getArgument('--repo');
|
|
$outputDir = $this->getArgument('--output');
|
|
|
|
if ($outputDir === '' || $outputDir === null) {
|
|
$outputDir = sys_get_temp_dir();
|
|
}
|
|
|
|
// Allow token from environment
|
|
if ($token === '' || $token === null) {
|
|
$token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: '');
|
|
}
|
|
|
|
if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
|
|
$this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL");
|
|
$this->log('ERROR', " --repo REPO Repo name for element detection fallback");
|
|
$this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())");
|
|
$this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var");
|
|
return 1;
|
|
}
|
|
|
|
$root = realpath($path) ?: $path;
|
|
|
|
// ── Read platform from .mokogitea/manifest.xml ───────────────────────
|
|
$detectedPlatform = 'generic';
|
|
$detectedEntryPoint = '';
|
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
|
if (file_exists($mokoManifest)) {
|
|
$mokoXml = @simplexml_load_file($mokoManifest);
|
|
if ($mokoXml !== false) {
|
|
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
|
if ($rawPlatform !== '') {
|
|
$detectedPlatform = match ($rawPlatform) {
|
|
'waas-component' => 'joomla',
|
|
'crm-module' => 'dolibarr',
|
|
default => $rawPlatform,
|
|
};
|
|
}
|
|
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
|
|
}
|
|
}
|
|
|
|
// ── Detect element metadata from manifest XML ────────────────────────
|
|
$extElement = '';
|
|
$extType = '';
|
|
$extFolder = '';
|
|
$typePrefix = '';
|
|
|
|
SourceResolver::warnIfLegacy($root);
|
|
$manifestFiles = array_merge(
|
|
SourceResolver::globSource($root, 'pkg_*.xml'),
|
|
SourceResolver::globSource($root, '*.xml'),
|
|
glob("{$root}/*.xml") ?: []
|
|
);
|
|
|
|
$extManifest = null;
|
|
foreach ($manifestFiles as $file) {
|
|
$content = file_get_contents($file);
|
|
if ($content !== false && strpos($content, '<extension') !== false) {
|
|
$extManifest = $file;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($extManifest !== null) {
|
|
$xml = file_get_contents($extManifest);
|
|
if ($xml === false) {
|
|
$xml = '';
|
|
}
|
|
|
|
// Extension type and folder
|
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
|
$extType = $tm[1];
|
|
}
|
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
|
$extFolder = $gm[1];
|
|
}
|
|
|
|
// Element name
|
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
|
$extElement = $em[1];
|
|
}
|
|
if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) {
|
|
$extElement = $mm[1];
|
|
}
|
|
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
|
$extElement = $pm[1];
|
|
}
|
|
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
|
$extElement = $pn[1];
|
|
}
|
|
if ($extElement === '') {
|
|
$extElement = strtolower(basename($extManifest, '.xml'));
|
|
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to repo name
|
|
if ($extElement === '') {
|
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
|
}
|
|
|
|
// Strip existing type prefix to prevent duplication
|
|
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
|
|
|
// Compute type prefix
|
|
switch ($extType) {
|
|
case 'plugin':
|
|
$typePrefix = "plg_{$extFolder}_";
|
|
break;
|
|
case 'module':
|
|
$typePrefix = 'mod_';
|
|
break;
|
|
case 'component':
|
|
$typePrefix = 'com_';
|
|
break;
|
|
case 'template':
|
|
$typePrefix = 'tpl_';
|
|
break;
|
|
case 'library':
|
|
$typePrefix = 'lib_';
|
|
break;
|
|
case 'package':
|
|
$typePrefix = 'pkg_';
|
|
break;
|
|
}
|
|
|
|
echo "Element: {$typePrefix}{$extElement}\n";
|
|
echo "Type: {$extType}\n";
|
|
|
|
// ── Compute filenames ────────────────────────────────────────────────
|
|
$baseName = "{$typePrefix}{$extElement}-{$version}";
|
|
$zipFile = "{$outputDir}/{$baseName}.zip";
|
|
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
|
|
|
|
echo "ZIP: {$baseName}.zip\n";
|
|
echo "TAR: {$baseName}.tar.gz\n";
|
|
|
|
// ── Find source directory ────────────────────────────────────────────
|
|
$sourceDir = null;
|
|
|
|
if ($detectedEntryPoint !== '') {
|
|
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
|
|
if (is_dir("{$root}/{$entryDir}")) {
|
|
$sourceDir = "{$root}/{$entryDir}";
|
|
}
|
|
}
|
|
|
|
if ($sourceDir === null) {
|
|
$sourceDir = SourceResolver::resolveAbsolute($root);
|
|
}
|
|
|
|
if ($sourceDir === null) {
|
|
echo "No source/ or src/ directory found — skipping package build\n";
|
|
return 0;
|
|
}
|
|
|
|
echo "Source: {$sourceDir}\n";
|
|
|
|
// ── Build packages ───────────────────────────────────────────────────
|
|
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
|
|
|
if ($isJoomlaPackage) {
|
|
echo "Building Joomla package (sub-extensions)...\n";
|
|
|
|
$zip = new \ZipArchive();
|
|
if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
|
$this->log('ERROR', "Failed to create ZIP: {$zipFile}");
|
|
return 1;
|
|
}
|
|
|
|
// Only zip sub-extensions listed in the package manifest
|
|
$manifestedSubs = [];
|
|
$pkgManifestFiles = glob("{$sourceDir}/pkg_*.xml") ?: [];
|
|
foreach ($pkgManifestFiles as $pmf) {
|
|
$pmXml = @simplexml_load_file($pmf);
|
|
if ($pmXml && isset($pmXml->files)) {
|
|
foreach ($pmXml->files->file as $fileNode) {
|
|
$zipName = pathinfo((string) $fileNode, PATHINFO_FILENAME);
|
|
if (!empty($zipName)) {
|
|
$manifestedSubs[$zipName] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
|
|
foreach ($packageDirs as $pkgDir) {
|
|
$subName = basename($pkgDir);
|
|
|
|
// Skip directories not listed in the package manifest
|
|
if (!empty($manifestedSubs) && !isset($manifestedSubs[$subName])) {
|
|
echo " Skipping {$subName} (not in package manifest)\n";
|
|
continue;
|
|
}
|
|
$subZipPath = "{$outputDir}/{$subName}.zip";
|
|
|
|
// Use pre-built ZIP if staged in the output directory
|
|
if (file_exists($subZipPath) && filesize($subZipPath) > 0) {
|
|
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
|
$sizeKb = number_format(filesize($subZipPath) / 1024, 1);
|
|
echo " Sub-package: {$subName}.zip (pre-built, {$sizeKb} KB)\n";
|
|
continue;
|
|
}
|
|
|
|
// If sub-package is a full repo checkout (e.g. git submodule),
|
|
// look for a source/ or src/ subdirectory containing a Joomla manifest XML
|
|
// and zip that instead of the repo root.
|
|
$subSourceDir = $pkgDir;
|
|
$subSrcAbs = SourceResolver::resolveAbsolute($pkgDir);
|
|
if ($subSrcAbs !== null) {
|
|
$srcManifests = array_merge(
|
|
glob("{$subSrcAbs}/*.xml") ?: [],
|
|
glob("{$subSrcAbs}/pkg_*.xml") ?: []
|
|
);
|
|
foreach ($srcManifests as $mf) {
|
|
if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) {
|
|
$subSourceDir = $subSrcAbs;
|
|
$subSrcName = SourceResolver::resolve($pkgDir);
|
|
echo " Sub-package {$subName}: using {$subSrcName}/ entry-point\n";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If source dir has no manifest, the submodule may be empty.
|
|
// Try to download the pre-built release from Gitea.
|
|
$hasManifest = !empty(glob("{$subSourceDir}/*.xml") ?: []);
|
|
if (!$hasManifest && $token !== '' && $this->downloadSubmoduleRelease($root, $subName, $subZipPath, $token)) {
|
|
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
|
$sizeKb = number_format(filesize($subZipPath) / 1024, 1);
|
|
echo " Sub-package: {$subName}.zip (downloaded release, {$sizeKb} KB)\n";
|
|
continue;
|
|
}
|
|
|
|
$subZip = new \ZipArchive();
|
|
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
|
$this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}");
|
|
continue;
|
|
}
|
|
$this->addDirToZip($subZip, $subSourceDir, '', $this->excludePatterns);
|
|
$subZip->close();
|
|
|
|
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
|
echo " Sub-package: {$subName}.zip\n";
|
|
}
|
|
|
|
$pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: [];
|
|
foreach ($pkgManifests as $pkgXml) {
|
|
$pkgContent = file_get_contents($pkgXml);
|
|
if (strpos($pkgContent, '<files>') !== false && strpos($pkgContent, 'folder="packages"') === false) {
|
|
$pkgContent = str_replace('<files>', '<files folder="packages">', $pkgContent);
|
|
file_put_contents($pkgXml, $pkgContent);
|
|
echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n";
|
|
}
|
|
}
|
|
|
|
$topLevelFiles = array_merge(
|
|
glob("{$sourceDir}/*.xml") ?: [],
|
|
glob("{$sourceDir}/*.php") ?: []
|
|
);
|
|
foreach ($topLevelFiles as $tlFile) {
|
|
if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) {
|
|
$zip->addFile($tlFile, basename($tlFile));
|
|
}
|
|
}
|
|
|
|
$topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: [];
|
|
foreach ($topLevelDirs as $tlDir) {
|
|
$dirName = basename($tlDir);
|
|
if ($dirName === 'packages') {
|
|
continue;
|
|
}
|
|
$this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns);
|
|
echo " Included dir: {$dirName}/\n";
|
|
}
|
|
|
|
$zip->close();
|
|
echo "ZIP created: {$zipFile}\n";
|
|
} else {
|
|
echo "Building standard extension ZIP...\n";
|
|
|
|
$zip = new \ZipArchive();
|
|
if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
|
$this->log('ERROR', "Failed to create ZIP: {$zipFile}");
|
|
return 1;
|
|
}
|
|
$this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns);
|
|
$zip->close();
|
|
echo "ZIP created: {$zipFile}\n";
|
|
}
|
|
|
|
// ── Build tar.gz ─────────────────────────────────────────────────────
|
|
$tarExcludeArgs = [];
|
|
foreach ($this->excludePatterns as $pattern) {
|
|
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
|
|
}
|
|
|
|
$tarCommand = sprintf(
|
|
'tar -czf %s -C %s %s .',
|
|
escapeshellarg($tarFile),
|
|
escapeshellarg($sourceDir),
|
|
implode(' ', $tarExcludeArgs)
|
|
);
|
|
|
|
$tarReturnCode = 0;
|
|
$tarOutputLines = [];
|
|
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
|
|
|
|
if (!file_exists($tarFile)) {
|
|
$this->log('ERROR', "Failed to create tar.gz: {$tarFile}");
|
|
if ($tarOutputLines !== []) {
|
|
$this->log('ERROR', implode("\n", $tarOutputLines));
|
|
}
|
|
return 1;
|
|
}
|
|
echo "TAR created: {$tarFile}\n";
|
|
|
|
// ── Compute SHA-256 checksums ────────────────────────────────────────
|
|
$zipHash = hash_file('sha256', $zipFile);
|
|
$tarHash = hash_file('sha256', $tarFile);
|
|
|
|
if ($zipHash === false || $tarHash === false) {
|
|
$this->log('ERROR', "Failed to compute SHA-256 checksums");
|
|
return 1;
|
|
}
|
|
|
|
$zipSha = "{$zipFile}.sha256";
|
|
$tarSha = "{$tarFile}.sha256";
|
|
|
|
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
|
|
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
|
|
|
|
echo "SHA-256 (ZIP): {$zipHash}\n";
|
|
echo "SHA-256 (TAR): {$tarHash}\n";
|
|
echo "sha256_zip={$zipHash}\n";
|
|
echo "zip_name={$baseName}.zip\n";
|
|
|
|
// Write to GITHUB_OUTPUT if available
|
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
|
if ($ghOutput) {
|
|
file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND);
|
|
}
|
|
|
|
// ── Get release ID from tag ──────────────────────────────────────────
|
|
$result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
|
|
if ($result['data'] === null || !isset($result['data']['id'])) {
|
|
$this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})");
|
|
return 1;
|
|
}
|
|
|
|
$releaseId = (int) $result['data']['id'];
|
|
echo "Release ID: {$releaseId} (tag: {$tag})\n";
|
|
|
|
// ── Delete existing assets with same names ───────────────────────────
|
|
$assetsResult = $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
|
|
$existingAssets = $assetsResult['data'] ?? [];
|
|
|
|
$uploadNames = [
|
|
"{$baseName}.zip",
|
|
"{$baseName}.tar.gz",
|
|
"{$baseName}.zip.sha256",
|
|
"{$baseName}.tar.gz.sha256",
|
|
];
|
|
|
|
foreach ($existingAssets as $asset) {
|
|
if (!is_array($asset)) {
|
|
continue;
|
|
}
|
|
$assetName = $asset['name'] ?? '';
|
|
$assetId = $asset['id'] ?? 0;
|
|
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
|
|
$this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
|
|
echo "Deleted existing asset: {$assetName}\n";
|
|
}
|
|
}
|
|
|
|
// ── Upload assets ────────────────────────────────────────────────────
|
|
$filesToUpload = [
|
|
"{$baseName}.zip" => $zipFile,
|
|
"{$baseName}.tar.gz" => $tarFile,
|
|
"{$baseName}.zip.sha256" => $zipSha,
|
|
"{$baseName}.tar.gz.sha256" => $tarSha,
|
|
];
|
|
|
|
$uploaded = 0;
|
|
foreach ($filesToUpload as $name => $localPath) {
|
|
if (!file_exists($localPath)) {
|
|
$this->log('ERROR', "File not found, skipping: {$localPath}");
|
|
continue;
|
|
}
|
|
|
|
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
|
|
$httpCode = $this->giteaUploadAsset($uploadUrl, $token, $localPath);
|
|
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
|
|
echo "Upload: {$name} — {$status}\n";
|
|
|
|
if ($httpCode >= 200 && $httpCode < 300) {
|
|
$uploaded++;
|
|
}
|
|
}
|
|
|
|
// ── Summary ──────────────────────────────────────────────────────────
|
|
echo "\n";
|
|
echo "Package build complete\n";
|
|
echo " Element: {$typePrefix}{$extElement}\n";
|
|
echo " Version: {$version}\n";
|
|
echo " Tag: {$tag}\n";
|
|
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
|
|
|
|
return $uploaded === count($filesToUpload) ? 0 : 1;
|
|
}
|
|
|
|
/**
|
|
* Perform a Gitea API request.
|
|
*
|
|
* @return array{data: array<string, mixed>|null, code: int}
|
|
*/
|
|
private function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array
|
|
{
|
|
$ch = curl_init($url);
|
|
if ($ch === false) {
|
|
return ['data' => null, 'code' => 0];
|
|
}
|
|
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 || !is_string($response) || $response === '') {
|
|
return ['data' => null, 'code' => $httpCode];
|
|
}
|
|
|
|
$decoded = json_decode($response, true);
|
|
return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode];
|
|
}
|
|
|
|
/**
|
|
* Upload a file as a release asset.
|
|
*/
|
|
private function giteaUploadAsset(string $url, string $token, string $filePath): int
|
|
{
|
|
$ch = curl_init($url);
|
|
if ($ch === false) {
|
|
return 0;
|
|
}
|
|
$fileContent = file_get_contents($filePath);
|
|
if ($fileContent === false) {
|
|
return 0;
|
|
}
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
"Authorization: token {$token}",
|
|
'Content-Type: application/octet-stream',
|
|
],
|
|
CURLOPT_POSTFIELDS => $fileContent,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 120,
|
|
]);
|
|
curl_exec($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
return $httpCode;
|
|
}
|
|
|
|
/**
|
|
* Check if a filename matches any exclusion pattern.
|
|
*/
|
|
private function isExcluded(string $filename, array $patterns): bool
|
|
{
|
|
$basename = basename($filename);
|
|
foreach ($patterns as $pattern) {
|
|
if (fnmatch($pattern, $basename)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Download a pre-built release ZIP for a sub-package that is a git submodule
|
|
* with an empty or missing source directory.
|
|
*
|
|
* Reads .gitmodules to find the submodule's remote URL, derives the Gitea
|
|
* API path, and downloads the latest stable release asset.
|
|
*/
|
|
private function downloadSubmoduleRelease(string $root, string $subName, string $destPath, string $token): bool
|
|
{
|
|
$gitmodulesPath = "{$root}/.gitmodules";
|
|
if (!file_exists($gitmodulesPath)) {
|
|
return false;
|
|
}
|
|
|
|
$gitmodules = file_get_contents($gitmodulesPath);
|
|
if ($gitmodules === false) {
|
|
return false;
|
|
}
|
|
|
|
// Find the submodule URL by matching the subName in the path
|
|
if (!preg_match('/\[submodule\s[^\]]*\]\s*\n\s*path\s*=\s*[^\n]*' . preg_quote($subName, '/') . '\s*\n\s*url\s*=\s*(\S+)/m', $gitmodules, $matches)) {
|
|
return false;
|
|
}
|
|
|
|
$remoteUrl = preg_replace('/\.git$/', '', $matches[1]);
|
|
|
|
// Extract org/repo from the URL
|
|
if (!preg_match('#[/:]([^/]+)/([^/]+)$#', $remoteUrl, $parts)) {
|
|
return false;
|
|
}
|
|
|
|
$org = $parts[1];
|
|
$repo = $parts[2];
|
|
|
|
// Derive the Gitea API base from the remote URL
|
|
$parsed = parse_url($remoteUrl);
|
|
$scheme = $parsed['scheme'] ?? 'https';
|
|
$host = $parsed['host'] ?? '';
|
|
if ($host === '') {
|
|
return false;
|
|
}
|
|
$apiBase = "{$scheme}://{$host}/api/v1/repos/{$org}/{$repo}";
|
|
|
|
echo " Submodule {$subName}: source empty, downloading release from {$org}/{$repo}...\n";
|
|
|
|
// Get the stable release
|
|
$result = $this->giteaApiRequest("{$apiBase}/releases/tags/stable", $token);
|
|
if ($result['data'] === null || !isset($result['data']['assets'])) {
|
|
echo " WARNING: No stable release found for {$org}/{$repo}\n";
|
|
return false;
|
|
}
|
|
|
|
// Find the ZIP asset (not .sha256)
|
|
$downloadUrl = '';
|
|
foreach ($result['data']['assets'] as $asset) {
|
|
if (!is_array($asset)) {
|
|
continue;
|
|
}
|
|
$name = $asset['name'] ?? '';
|
|
if (str_ends_with($name, '.zip') && !str_ends_with($name, '.sha256')) {
|
|
$downloadUrl = $asset['browser_download_url'] ?? '';
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($downloadUrl === '') {
|
|
echo " WARNING: No ZIP asset in {$org}/{$repo} stable release\n";
|
|
return false;
|
|
}
|
|
|
|
// Download the ZIP
|
|
$ch = curl_init($downloadUrl);
|
|
if ($ch === false) {
|
|
return false;
|
|
}
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
|
CURLOPT_TIMEOUT => 120,
|
|
]);
|
|
$content = curl_exec($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode < 200 || $httpCode >= 300 || !is_string($content) || $content === '') {
|
|
echo " WARNING: Download failed (HTTP {$httpCode})\n";
|
|
return false;
|
|
}
|
|
|
|
if (file_put_contents($destPath, $content) === false) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Recursively add files from a directory to a ZipArchive.
|
|
*/
|
|
private function addDirToZip(\ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void
|
|
{
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($sourceDir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if (!$file instanceof \SplFileInfo || !$file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$realPath = $file->getRealPath();
|
|
if ($realPath === false) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->isExcluded($file->getFilename(), $excludes)) {
|
|
continue;
|
|
}
|
|
|
|
$relativePath = substr($realPath, strlen($sourceDir) + 1);
|
|
// Normalise to forward slashes for ZIP compatibility
|
|
$relativePath = str_replace('\\', '/', $relativePath);
|
|
$archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath;
|
|
$zip->addFile($realPath, $archivePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
$app = new ReleasePackageCli();
|
|
exit($app->execute());
|