diff --git a/.gitignore b/.gitignore index 178135c..849ddff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/dist/ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b6bd1a --- /dev/null +++ b/Makefile @@ -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 diff --git a/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php b/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php index 5ac1068..6e3bbd9 100644 --- a/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php +++ b/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php @@ -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; diff --git a/packages/commands/src/Toolbox/Config.php b/packages/commands/src/Toolbox/Config.php index 776743d..b425dd0 100644 --- a/packages/commands/src/Toolbox/Config.php +++ b/packages/commands/src/Toolbox/Config.php @@ -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 diff --git a/packages/commands/src/Toolbox/SelfUpdater.php b/packages/commands/src/Toolbox/SelfUpdater.php index 32f2c8e..0acb04e 100644 --- a/packages/commands/src/Toolbox/SelfUpdater.php +++ b/packages/commands/src/Toolbox/SelfUpdater.php @@ -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; } } diff --git a/scripts/publish-phar b/scripts/publish-phar new file mode 100755 index 0000000..cd93d01 --- /dev/null +++ b/scripts/publish-phar @@ -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" diff --git a/tests/group10_release_test.php b/tests/group10_release_test.php index bead003..24f7fdd 100644 --- a/tests/group10_release_test.php +++ b/tests/group10_release_test.php @@ -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 '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 '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');