feat(self-update): resolve latest via Forgejo/gitea releases API
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
/dist/
|
||||
dist/
|
||||
|
||||
@@ -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\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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+72
@@ -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"
|
||||
@@ -47,6 +47,103 @@ Harness::it('self-update aborta ante sha256 inválido sin tocar el binario', fun
|
||||
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 () {
|
||||
$repo = $GLOBALS['g10_repo'];
|
||||
$dist = tmpdir('dist');
|
||||
|
||||
Reference in New Issue
Block a user