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
+1 -1
View File
@@ -1 +1 @@
/dist/ dist/
+25
View File
@@ -0,0 +1,25 @@
# duckbrain-toolbox: atajos del ciclo de desarrollo y publicacion del phar.
# `publish` requiere FORGEJO_TOKEN en el entorno (token de la API con write:repository).
CONFIG := packages/commands/src/Toolbox/Config.php
VERSION := $(shell grep -oP "const VERSION = '\K[^']+" $(CONFIG))
.PHONY: help test build publish check clean
help: ## Muestra esta ayuda
@grep -hE '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | sed -E 's/^([a-z-]+):.*## (.+)$$/ make \1 \xe2\x80\x94 \2/'
test: ## Corre la suite de pruebas (harness propio, sin phpunit)
php tests/run.php
build: ## Construye dist/duckbrain.phar y su .sha256
scripts/build-phar
publish: build ## Crea/mueve la release v$(VERSION) en Forgejo y sube los assets
scripts/publish-phar
check: ## Pregunta al servidor si hay un instalador mas nuevo (exit 1 si la hay)
php bin/duckbrain self-update --check
clean: ## Borra dist/
rm -rf dist
@@ -4,6 +4,7 @@ namespace Toolbox\Commands;
use Toolbox\Config; use Toolbox\Config;
use Toolbox\SelfUpdater; use Toolbox\SelfUpdater;
use Toolbox\Semver;
use Toolbox\ToolboxException; use Toolbox\ToolboxException;
final class SelfUpdateCommand extends Command final class SelfUpdateCommand extends Command
@@ -19,14 +20,24 @@ final class SelfUpdateCommand extends Command
$from = $this->option($parsed['options'], 'from', Config::updateUrl()); $from = $this->option($parsed['options'], 'from', Config::updateUrl());
$target = $this->option($parsed['options'], 'target', Config::binPath()); $target = $this->option($parsed['options'], 'target', Config::binPath());
$temp = $this->option($parsed['options'], 'temp', sys_get_temp_dir()); $temp = $this->option($parsed['options'], 'temp', sys_get_temp_dir());
$force = $this->flag($parsed['options'], 'force');
if ($from === null || $target === null) { if ($from === null || $target === null) {
$this->console->error('Faltan --from o --target.'); $this->console->error('Faltan --from o --target.');
return 1; return 1;
} }
$updater = new SelfUpdater($this->console);
try { 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) { } catch (ToolboxException $e) {
$this->console->error($e->getMessage()); $this->console->error($e->getMessage());
return 1; return 1;
+1 -1
View File
@@ -49,7 +49,7 @@ final class Config
public static function updateUrl(): string public static function updateUrl(): string
{ {
return self::env('DUCKBRAIN_UPDATE_URL') 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 public static function binPath(): string
+108 -6
View File
@@ -3,8 +3,10 @@
namespace Toolbox; namespace Toolbox;
/** /**
* Autoactualización del instalador global: descarga el phar, verifica su * Autoactualización del instalador global: detecta la última release vía la
* sha256 contra el publicado y reemplaza el binario de forma atómica. * 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 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'; [$source, $sumUrl, $tag] = $this->resolve($from);
$sumUrl = $source . '.sha256';
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); $this->console->out(' descargando ' . $source);
$data = @file_get_contents($source); $data = @file_get_contents($source);
@@ -46,6 +55,99 @@ final class SelfUpdater
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}"); 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;
} }
} }
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Publica dist/duckbrain.phar (+ su sha256) como assets de la release `vX.Y.Z`
# en Forgejo, usando la version del instalador (Config::VERSION) como tag.
# self-update detecta la nueva version eligiendo el mayor semver de la lista
# de releases; no se necesita ningun tag magico `latest`.
# Requiere FORGEJO_TOKEN (token de la API con scope write:repository).
# Variables: FORGEJO_URL, FORGEJO_REPO, DUCKBRAIN_DIST.
set -euo pipefail
: "${FORGEJO_TOKEN:?Define FORGEJO_TOKEN (token de la API de Forgejo con write:repository)}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="${DUCKBRAIN_DIST:-$ROOT/dist}"
FORGEJO_URL="${FORGEJO_URL:-https://git.kj2.me}"
FORGEJO_REPO="${FORGEJO_REPO:-kj/duckbrain-toolbox}"
API="$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO"
VERSION="$(grep -oP "const VERSION = '\K[^']+" "$ROOT/packages/commands/src/Toolbox/Config.php")"
TAG="v$VERSION"
if [ ! -f "$OUT/duckbrain.phar" ] || [ ! -f "$OUT/duckbrain.phar.sha256" ]; then
"$ROOT/scripts/build-phar"
fi
# El tag apunta al commit actual (de donde se construye el phar).
if ! git -C "$ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git -C "$ROOT" tag -a "$TAG" -m "duckbrain-toolbox $TAG"
fi
git -C "$ROOT" push -q origin "refs/tags/$TAG"
body="$(mktemp)"
trap 'rm -f "$body"' EXIT
code="$(curl -s -o "$body" -w '%{http_code}' -X POST "$API/releases" \
-H "Authorization: token $FORGEJO_TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}")"
if [ "$code" = 409 ] || [ "$code" = 422 ]; then
code="$(curl -s -o "$body" -w '%{http_code}' -H "Authorization: token $FORGEJO_TOKEN" "$API/releases/tags/$TAG")"
fi
if [ "$code" != 200 ] && [ "$code" != 201 ]; then
echo "Forgejo respondio HTTP $code al crear/leer la release $TAG:" >&2
cat "$body" >&2
exit 1
fi
id="$(jq -r '.id // empty' "$body")"
if [ -z "$id" ]; then
echo "La respuesta no trae id de release:" >&2
cat "$body" >&2
exit 1
fi
# Idempotente: quita assets previos con los nombres que se van a subir.
for a in $(jq -r '.assets[]? | select(.name == "duckbrain.phar" or .name == "duckbrain.phar.sha256") | .id' "$body"); do
curl -s -o /dev/null -X DELETE -H "Authorization: token $FORGEJO_TOKEN" "$API/releases/assets/$a"
done
for f in duckbrain.phar duckbrain.phar.sha256; do
resp="$(mktemp)"
if ! curl -s -f -o "$resp" -X POST "$API/releases/$id/assets?name=$f" \
-H "Authorization: token $FORGEJO_TOKEN" -F "attachment=@$OUT/$f"; then
echo "Fallo subiendo $f:" >&2
cat "$resp" >&2
exit 1
fi
rm -f "$resp"
done
echo "OK: release $TAG publicada con duckbrain.phar + duckbrain.phar.sha256"
echo " $FORGEJO_URL/$FORGEJO_REPO/releases/tag/$TAG"
+97
View File
@@ -47,6 +47,103 @@ Harness::it('self-update aborta ante sha256 inválido sin tocar el binario', fun
rrmdir($work); rrmdir($work);
}); });
Harness::it('self-update en modo API reemplaza cuando el tag es más nuevo', function () {
$rel = tmpdir('rel');
$work = tmpdir('work');
$content = "#!/usr/bin/env php\n<?php // v9\n";
write_file($rel . '/duckbrain.phar', $content);
write_file($rel . '/duckbrain.phar.sha256', hash('sha256', $content));
$json = json_encode([
'tag_name' => 'v9.9.9',
'assets' => [
['name' => 'duckbrain.phar', 'browser_download_url' => 'file://' . $rel . '/duckbrain.phar'],
['name' => 'duckbrain.phar.sha256', 'browser_download_url' => 'file://' . $rel . '/duckbrain.phar.sha256'],
],
]);
write_file($rel . '/releases.json', $json);
$target = $work . '/duckbrain.phar';
write_file($target, 'viejo');
(new SelfUpdater(new Toolbox\Console()))->update($rel . '/releases.json', $target, $work);
eq($content, file_get_contents($target), 'binario reemplazado desde la release');
eq('v9.9.9', (new SelfUpdater(new Toolbox\Console()))->available($rel . '/releases.json'), 'available lee el tag');
rrmdir($rel);
rrmdir($work);
});
Harness::it('self-update en modo API no toca el binario si la versión ya es actual', function () {
$rel = tmpdir('rel');
$work = tmpdir('work');
write_file($rel . '/duckbrain.phar', "nuevo");
write_file($rel . '/duckbrain.phar.sha256', hash('sha256', 'nuevo'));
$json = json_encode([
'tag_name' => 'v' . Toolbox\Config::VERSION,
'assets' => [
['name' => 'duckbrain.phar', 'browser_download_url' => 'file://' . $rel . '/duckbrain.phar'],
['name' => 'duckbrain.phar.sha256', 'browser_download_url' => 'file://' . $rel . '/duckbrain.phar.sha256'],
],
]);
write_file($rel . '/releases.json', $json);
$target = $work . '/duckbrain.phar';
write_file($target, 'actual');
(new SelfUpdater(new Toolbox\Console()))->update($rel . '/releases.json', $target, $work);
eq('actual', file_get_contents($target), 'sin cambios cuando el tag no es más nuevo');
(new SelfUpdater(new Toolbox\Console()))->update($rel . '/releases.json', $target, $work, true);
eq('nuevo', file_get_contents($target), 'con --force reemplaza igualmente');
rrmdir($rel);
rrmdir($work);
});
Harness::it('self-update elige el mayor tag semver de la lista e ignora latest y drafts', function () {
$rel = tmpdir('rel');
$work = tmpdir('work');
$winner = "#!/usr/bin/env php\n<?php // v10\n";
$loser = "older";
write_file($rel . '/p10', $winner);
write_file($rel . '/p10.sha256', hash('sha256', $winner));
write_file($rel . '/p1', $loser);
write_file($rel . '/p1.sha256', hash('sha256', $loser));
$pair = function (string $file): array {
return [
['name' => 'duckbrain.phar', 'browser_download_url' => 'file://' . $file],
['name' => 'duckbrain.phar.sha256', 'browser_download_url' => 'file://' . $file . '.sha256'],
];
};
$list = [
['tag_name' => 'latest', 'draft' => false, 'assets' => $pair($rel . '/p1')],
['tag_name' => 'v99.0.0', 'draft' => true, 'assets' => $pair($rel . '/p1')],
['tag_name' => 'v1.2.3', 'draft' => false, 'assets' => $pair($rel . '/p1')],
['tag_name' => 'v10.0.0', 'draft' => false, 'assets' => $pair($rel . '/p10')],
];
write_file($rel . '/releases.json', json_encode($list));
$target = $work . '/duckbrain.phar';
write_file($target, 'semver');
$updater = new SelfUpdater(new Toolbox\Console());
eq('v10.0.0', $updater->available($rel . '/releases.json'), 'el mayor semver gana sobre latest, drafts y v1.2.3');
$updater->update($rel . '/releases.json', $target, $work);
eq($winner, file_get_contents($target), 'se instala el phar del mayor semver');
rrmdir($rel);
rrmdir($work);
});
Harness::it('build-phar genera un phar ejecutable con su sha256', function () { Harness::it('build-phar genera un phar ejecutable con su sha256', function () {
$repo = $GLOBALS['g10_repo']; $repo = $GLOBALS['g10_repo'];
$dist = tmpdir('dist'); $dist = tmpdir('dist');