feat(self-update): resolve latest via Forgejo/gitea releases API

This commit is contained in:
kj
2026-10-05 15:50:13 -03:00
parent 0220e97109
commit 7025ccf553
7 changed files with 316 additions and 9 deletions
@@ -4,6 +4,7 @@ namespace Toolbox\Commands;
use Toolbox\Config;
use Toolbox\SelfUpdater;
use Toolbox\Semver;
use Toolbox\ToolboxException;
final class SelfUpdateCommand extends Command
@@ -19,14 +20,24 @@ final class SelfUpdateCommand extends Command
$from = $this->option($parsed['options'], 'from', Config::updateUrl());
$target = $this->option($parsed['options'], 'target', Config::binPath());
$temp = $this->option($parsed['options'], 'temp', sys_get_temp_dir());
$force = $this->flag($parsed['options'], 'force');
if ($from === null || $target === null) {
$this->console->error('Faltan --from o --target.');
return 1;
}
$updater = new SelfUpdater($this->console);
try {
(new SelfUpdater($this->console))->update($from, $target, $temp);
if ($this->flag($parsed['options'], 'check')) {
$tag = $updater->available($from);
$this->console->out(' actual: v' . Config::VERSION);
$this->console->out(' disponible: ' . ($tag !== null ? 'v' . ltrim($tag, 'vV') : '(desconocido)'));
return $tag === null || Semver::compare(Config::VERSION, ltrim($tag, 'vV')) >= 0 ? 0 : 1;
}
$updater->update($from, $target, $temp, $force);
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
+1 -1
View File
@@ -49,7 +49,7 @@ final class Config
public static function updateUrl(): string
{
return self::env('DUCKBRAIN_UPDATE_URL')
?: 'https://git.kj2.me/kj/duckbrain-toolbox/releases/download/latest';
?: 'https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases';
}
public static function binPath(): string
+108 -6
View File
@@ -3,8 +3,10 @@
namespace Toolbox;
/**
* Autoactualización del instalador global: descarga el phar, verifica su
* sha256 contra el publicado y reemplaza el binario de forma atómica.
* Autoactualización del instalador global: detecta la última release vía la
* API de Forgejo/Gitea eligiendo el mayor tag semver de la lista (igual que
* el core con sus tags), o un directorio plano; descarga el phar, verifica
* su sha256 contra el publicado y reemplaza el binario de forma atómica.
*/
final class SelfUpdater
{
@@ -12,10 +14,17 @@ final class SelfUpdater
{
}
public function update(string $from, string $target, string $tempDir): void
public function update(string $from, string $target, string $tempDir, bool $force = false): void
{
$source = rtrim($from, '/') . '/duckbrain.phar';
$sumUrl = $source . '.sha256';
[$source, $sumUrl, $tag] = $this->resolve($from);
if ($tag !== null) {
$remote = ltrim($tag, 'vV');
if (!$force && Semver::compare(Config::VERSION, $remote) >= 0) {
$this->console->out(' ya está actualizado (v' . Config::VERSION . ')');
return;
}
}
$this->console->out(' descargando ' . $source);
$data = @file_get_contents($source);
@@ -46,6 +55,99 @@ final class SelfUpdater
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}");
}
$this->console->ok('instalador actualizado (' . $target . ')');
$this->console->ok('instalador actualizado' . ($tag !== null ? ' a v' . ltrim($tag, 'vV') : '') . ' (' . $target . ')');
}
public function available(string $from): ?string
{
return $this->resolve($from)[2];
}
/**
* @return array{0: string, 1: string, 2: ?string} [URL del phar, URL del sha256, tag o null]
*/
private function resolve(string $from): array
{
if (!preg_match('#/releases(\?|$|/)|\.json$#', $from)) {
$source = rtrim($from, '/') . '/duckbrain.phar';
return [$source, $source . '.sha256', null];
}
$json = @file_get_contents($from);
if ($json === false) {
throw new ToolboxException("No se pudo leer la release: {$from}");
}
$data = json_decode($json, true);
if (!is_array($data)) {
throw new ToolboxException("Respuesta de release inválida: {$from}");
}
$candidates = [];
foreach (array_is_list($data) ? $data : [$data] as $release) {
$tag = is_array($release) ? (string) ($release['tag_name'] ?? '') : '';
if ($tag === '' || (is_array($release) && ($release['draft'] ?? false)) || !is_array($release)) {
continue;
}
try {
$version = Semver::normalize($tag);
Semver::parse($version);
} catch (ToolboxException) {
continue;
}
$assets = $this->assets((array) $release, $from);
if ($assets === null) {
continue;
}
$candidates[] = ['version' => $version, 'tag' => $tag, 'phar' => $assets[0], 'sum' => $assets[1]];
}
if ($candidates === []) {
throw new ToolboxException("Ninguna release publicada en '{$from}' incluye duckbrain.phar con su sha256");
}
usort($candidates, static fn (array $a, array $b): int => Semver::compare($b['version'], $a['version']));
$best = $candidates[0];
return [$best['phar'], $best['sum'], $best['tag']];
}
/**
* @return array{0: string, 1: string}|null
*/
private function assets(array $release, string $from): ?array
{
$phar = $sum = null;
foreach ($release['assets'] ?? [] as $asset) {
$name = (string) ($asset['name'] ?? '');
if ($name === 'duckbrain.phar' || $name === 'duckbrain.phar.sha256') {
$url = $this->absolute((string) ($asset['browser_download_url'] ?? $asset['download_url'] ?? ''), $from);
if ($url === '') {
return null;
}
if ($name === 'duckbrain.phar') {
$phar = $url;
} else {
$sum = $url;
}
}
}
return $phar !== null && $sum !== null ? [$phar, $sum] : null;
}
private function absolute(string $url, string $base): string
{
if ($url === '' || preg_match('#^(https?|file)://#', $url)) {
return $url;
}
$parts = parse_url($base);
return ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . $url;
}
}