From 6e7b5729353907b85ce15ed0e254380b3ccdc9c3 Mon Sep 17 00:00:00 2001 From: kj Date: Mon, 5 Oct 2026 16:22:17 -0300 Subject: [PATCH] feat(release): introduce interactive mode --- Makefile | 5 +- README.org | 94 ++++++++++++++-- scripts/release | 180 ++++++++++++++++++++++++------ tests/group8_project_cli_test.php | 4 +- 4 files changed, 235 insertions(+), 48 deletions(-) diff --git a/Makefile b/Makefile index 0b6bd1a..e2113d1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ CONFIG := packages/commands/src/Toolbox/Config.php VERSION := $(shell grep -oP "const VERSION = '\K[^']+" $(CONFIG)) -.PHONY: help test build publish check clean +.PHONY: help test build release publish check clean help: ## Muestra esta ayuda @grep -hE '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | sed -E 's/^([a-z-]+):.*## (.+)$$/ make \1 \xe2\x80\x94 \2/' @@ -15,6 +15,9 @@ test: ## Corre la suite de pruebas (harness propio, sin phpunit) build: ## Construye dist/duckbrain.phar y su .sha256 scripts/build-phar +release: ## Asistente interactivo para versionar un paquete (scripts/release) + ./scripts/release $(if $(ARGS),$(ARGS),) + publish: build ## Crea/mueve la release v$(VERSION) en Forgejo y sube los assets scripts/publish-phar diff --git a/README.org b/README.org index 91c80d7..dae58f1 100644 --- a/README.org +++ b/README.org @@ -41,7 +41,11 @@ repositorio. #+BEGIN_SRC text duckbrain-toolbox/ bin/duckbrain instalador global (bootstrap) - scripts/release escritor único de versión + tag + src/autoload.php mapea Toolbox\ -> packages/commands/src/Toolbox/ + scripts/release version + tag + push (interactivo o directo) + scripts/build-phar construye dist/duckbrain.phar autocontenido + scripts/publish-phar publica el phar como release en Forgejo + Makefile test / build / release / publish / check / clean packages/ commands/ cli, migration, toolbox htmx/ htmlComponent, htmx @@ -73,6 +77,25 @@ duckbrain add crypto htmx # copias reales, listas para commitear propios addons sin el instalador global, instala el componente =commands:toolbox= (es un =add= más, no un requisito). A partir de ahí =./duckbrain= funciona solo. +Alternativa sin clone del repo: descargar el *phar autocontenido* publicado como +release y ejecutarlo directamente (los enlaces simbólicos no valen: +=self-update= reemplaza el binario con =rename()= sobre su propia ruta): + +#+BEGIN_SRC sh +VERSION=$(curl -s https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases \ + | jq -r '.[].tag_name' | grep -E '^v[0-9]' | sort -V | tail -1) +curl -fsSL "https://git.kj2.me/kj/duckbrain-toolbox/releases/download/$VERSION/duckbrain.phar" \ + -o ~/.local/bin/duckbrain +chmod +x ~/.local/bin/duckbrain + +duckbrain --version # ya es el instalador global; se actualiza solo: +duckbrain self-update +#+END_SRC + +(Para probar el tuyo local sin release, usa =cp dist/duckbrain.phar ~/.local/bin/duckbrain= +en lugar del =curl=; =make build= + =ln -s= solo sirve mientras no llames a +=self-update=.) + * Comandos ** =duckbrain install [dir]= @@ -212,18 +235,73 @@ desconocida y se fija por commit. * Versionado y releases -Cada paquete se versiona de forma independiente con tags prefijados -(=http-v0.1.0=, =crypto-v0.1.0=…). El tag es el descubrimiento; el *commit* es la -identidad, así que el lock pinea commits y no se rompe si un tag se mueve. +Hay *dos* sistemas de versión y no hay que confundirlos: -El release lo hace un único script: +1. *Paquetes/componentes* — la versión vive en =packages//duckbrain.json= y + se versiona de forma independiente con tags prefijados (=http-v0.1.0=, + =crypto-v0.1.0=…). La única forma de cambiarla es el asistente: + + #+BEGIN_SRC sh + make release # interactivo: elige + # paquete e incremento, + # muestra cambios + # pendientes y confirma + scripts/release [--yes] # modo directo + #+END_SRC + + Bumpea el manifiesto, commitea, crea el tag y lo publica. Al liberar + =commands= sincroniza también =Config::VERSION= en el mismo commit. El tag es el + descubrimiento; el *commit* es la identidad, así que el lock pinea commits y no + se rompe si un tag se mueve. Las versiones retiradas se listan en + =yanked.json=; las prereleases se omiten salvo =--pre=. Para detectar bumps + olvidados: =git log -v<última>..HEAD -- packages/= (vacío = al día). + +2. *Instalador/phar* — =Config::VERSION= (=packages/commands/src/Toolbox/Config.php=): + es lo que imprime la CLI y la referencia con la que =self-update= compara las + releases del servidor. No se edita a mano; la mantiene el release de + =commands=. Se publica con =make publish= (ver *Makefile*). + +* Makefile + +=make help= lista los objetivos: + +| Objetivo | Qué hace | +|---------------+------------------------------------------------------------| +| =make test= | Corre la suite (=php tests/run.php=). En verde antes de commitear. | +| =make build= | Genera =dist/duckbrain.phar= + =dist/duckbrain.phar.sha256=. | +| =make release= | Asistente interactivo de releases de paquetes (=ARGS="pkg minor --yes"= para modo directo). | +| =make publish= | =build= + publica la release =v$(Config::VERSION)= con los assets. Requiere =FORGEJO_TOKEN=. | +| =make check= | =self-update --check= contra el servidor: =actual/disponible=, exit 1 si hay nuevo. | +| =make clean= | Borra =dist/=. | + +El token para publicar es un token personal de la API de Forgejo +(*Ajustes → Aplicaciones*, scope =write:repository=, con caducidad y revocable). +Los PAT de Forgejo no pueden limitarse a un solo repositorio: para aislar el +riesgo, crea un *usuario bot* con acceso Write solo en =duckbrain-toolbox= y usa +su token. =dist/= está en gitignore: nunca se commitea. + +* self-update (el phar) #+BEGIN_SRC sh -scripts/release +duckbrain self-update # se auto-reemplaza con la última release +duckbrain self-update --check # actual vs disponible (exit 1 si hay nueva) +duckbrain self-update --force # reemplaza aunque esté al día #+END_SRC -Bumpea la versión del manifiesto, commitea, crea el tag y publica. Las versiones -retiradas se listan en =yanked.json=; las prereleases se omiten salvo =--pre=. +Lee la lista de releases +(=https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases=, sobrescribible +con =DUCKBRAIN_UPDATE_URL=) y elige el *mayor tag semver* con la misma clase que +el core usa con sus tags; tags que no son semver y drafts se ignoran. No +existe la ruta =/releases/latest/download/= en Forgejo, así que no se depende de +ella: basta con subir =make publish= la versión real =vX.Y.Z=. La descarga se +verifica contra el =.sha256= publicado y el reemplazo es atómico (temp + rename) +sobre =Phar::running(false)=. Si =Config::VERSION >= remota= no se descarga nada. + +Para tocar el instalador con el phar en mente: =build-phar= ya ejecuta con +=php -d phar.readonly=0= (crear phars está bloqueado por defecto), el código que +corre *dentro* del phar no debe usar =glob()= (no soporta el stream =phar://=; +usa =DirectoryIterator= / =file_get_contents()=), y la raíz del toolbox se +resuelve con =Config::toolboxDir()=, que ya antepone =phar://= cuando toca. * Pruebas diff --git a/scripts/release b/scripts/release index d2d65aa..8b493db 100755 --- a/scripts/release +++ b/scripts/release @@ -1,46 +1,143 @@ #!/usr/bin/env bash -# Escritor único de versión + tag de un paquete del toolbox. -# Uso: scripts/release +# Unico escritor de version + tag + push de un paquete del toolbox. +# Interactivo: scripts/release (o `make release`) +# Directo: scripts/release [--yes] +# Al liberar `commands` sincroniza Config::VERSION con la version del manifiesto. set -euo pipefail -if [ "$#" -lt 2 ]; then - echo "Uso: scripts/release " >&2 - exit 1 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG_PHP="$ROOT/packages/commands/src/Toolbox/Config.php" + +die() { echo "$*" >&2; exit 1; } + +usage() { + echo "Uso: scripts/release [ ] [--yes]" >&2 + echo " sin argumentos: modo interactivo (o 'make release')" >&2 +} + +packages() { + local f + for f in "$ROOT"/packages/*/duckbrain.json; do + [ -f "$f" ] && basename "$(dirname "$f")" + done +} + +manifest() { echo "$ROOT/packages/$1/duckbrain.json"; } + +version_of() { + php -r '$d=json_decode(file_get_contents($argv[1]),true); echo $d["version"] ?? "";' "$(manifest "$1")" +} + +bump() { + php -r ' + $v = ltrim($argv[1], "vV"); + $p = array_map("intval", array_pad(explode(".", preg_replace("/[-+].*/", "", $v)), 3, 0)); + switch ($argv[2]) { + case "major": $p[0]++; $p[1] = 0; $p[2] = 0; break; + case "minor": $p[1]++; $p[2] = 0; break; + default: $p[2]++; + } + echo implode(".", $p);' "$1" "$2" +} + +ask() { + local prompt="$1" reply + printf '%s' "$prompt" >&2 + read -r reply || die "entrada agotada; pasa los argumentos o correlo en un TTY" + printf '%s' "$reply" +} + +ASSUME_YES=0 +POSITIONAL=() +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + *) POSITIONAL+=("$arg") ;; + esac +done + +confirm() { + [ "$ASSUME_YES" = "1" ] && return 0 + local r + r="$(ask "$1 [Y/n] ")" + case "${r,,}" in ""|y|yes) return 0 ;; *) return 1 ;; esac +} + +# --- eleccion de paquete ----------------------------------------------------- +if [ "${#POSITIONAL[@]}" -ge 1 ]; then + PACKAGE="${POSITIONAL[0]}" +else + mapfile -t PKGS < <(packages) + [ "${#PKGS[@]}" -gt 0 ] || die "No hay paquetes en $ROOT/packages/" + echo "Paquetes del toolbox:" + for i in "${!PKGS[@]}"; do + printf ' %2d) %-10s v%s\n' "$((i + 1))" "${PKGS[i]}" "$(version_of "${PKGS[i]}")" + done + while :; do + R="$(ask 'Elegir paquete (numero o nombre): ')" + if [[ "$R" =~ ^[0-9]+$ ]] && [ "$R" -ge 1 ] && [ "$R" -le "${#PKGS[@]}" ]; then + PACKAGE="${PKGS[R - 1]}" + break + fi + printf '%s\n' "${PKGS[@]}" | grep -qxF "$R" && { PACKAGE="$R"; break; } + echo " opcion invalida" >&2 + done fi -PACKAGE="$1" -TYPE="$2" -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MANIFEST="$ROOT/packages/$PACKAGE/duckbrain.json" +MANIFEST="$(manifest "$PACKAGE")" +[ -f "$MANIFEST" ] || die "No existe el paquete '$PACKAGE' ($MANIFEST)" -if [ ! -f "$MANIFEST" ]; then - echo "No existe el paquete '$PACKAGE' ($MANIFEST)" >&2 - exit 1 +# --- eleccion de incremento --------------------------------------------------- +if [ "${#POSITIONAL[@]}" -ge 2 ]; then + TYPE="${POSITIONAL[1]}" +else + while :; do + printf 'Incremento para %s (actual v%s):\n 1) patch\n 2) minor\n 3) major\n' "$PACKAGE" "$(version_of "$PACKAGE")" >&2 + R="$(ask 'Elegir (numero o nombre): ')" + case "$R" in + 1|patch) TYPE=patch; break ;; + 2|minor) TYPE=minor; break ;; + 3|major) TYPE=major; break ;; + esac + echo " opcion invalida" >&2 + done fi case "$TYPE" in patch|minor|major) ;; - *) echo "Incremento inválido: '$TYPE' (usa patch|minor|major)" >&2; exit 1 ;; + *) die "Incremento invalido: '$TYPE' (usa patch|minor|major)" ;; esac -VERSION="$(php -r '$d=json_decode(file_get_contents($argv[1]),true); echo $d["version"] ?? "";' "$MANIFEST")" -if [ -z "$VERSION" ]; then - echo "El manifiesto no declara 'version': $MANIFEST" >&2 - exit 1 +VERSION="$(version_of "$PACKAGE")" +[ -n "$VERSION" ] || die "El manifiesto no declara 'version': $MANIFEST" +NEW_VERSION="$(bump "$VERSION" "$TYPE")" +TAG="$PACKAGE-v$NEW_VERSION" + +CURRENT_CONFIG="" +[ "$PACKAGE" = "commands" ] && CURRENT_CONFIG="$(grep -oP "const VERSION = '\K[^']+" "$CONFIG_PHP" || true)" + +LAST_TAG="$(git -C "$ROOT" tag -l "$PACKAGE-v*" --sort=-v:refname | head -1 || true)" +if [ -n "$LAST_TAG" ]; then + CHANGES="$(git -C "$ROOT" log --oneline "$LAST_TAG"..HEAD -- "packages/$PACKAGE" || true)" +else + CHANGES="$(git -C "$ROOT" log --oneline HEAD -- "packages/$PACKAGE" || true)" fi -NEW_VERSION="$(php -r ' -$v = ltrim($argv[1], "vV"); -$type = $argv[2]; -$p = explode(".", preg_replace("/[-+].*/", "", $v)); -$p = array_pad($p, 3, 0); -$p = array_map("intval", $p); -if ($type === "major") { $p[0]++; $p[1] = 0; $p[2] = 0; } -elseif ($type === "minor") { $p[1]++; $p[2] = 0; } -else { $p[2]++; } -echo implode(".", $p); -' "$VERSION" "$TYPE")" +# --- resumen y confirmacion --------------------------------------------------- +echo "Release: $PACKAGE v$VERSION -> v$NEW_VERSION (tag $TAG)" +if [ "$PACKAGE" = "commands" ] && [ "$CURRENT_CONFIG" != "$NEW_VERSION" ]; then + echo " Config::VERSION: v$CURRENT_CONFIG -> v$NEW_VERSION (se sincroniza)" +fi +if [ -z "$CHANGES" ]; then + echo "Cambios desde el ultimo release: ninguno" +else + echo "Cambios desde el ultimo release:" + echo "$CHANGES" | sed 's/^/ /' +fi +confirm "Confirmar release?" || die "Cancelado; sin cambios." + +# --- aplicar ------------------------------------------------------------------- php -r ' $file = $argv[1]; $data = json_decode(file_get_contents($file), true); @@ -48,14 +145,23 @@ $data["version"] = $argv[2]; file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); ' "$MANIFEST" "$NEW_VERSION" -TAG="$PACKAGE-v$NEW_VERSION" - -git -C "$ROOT" add -- "$MANIFEST" -git -C "$ROOT" commit -q -m "release($PACKAGE): v$NEW_VERSION" -git -C "$ROOT" tag -a "$TAG" -m "release($PACKAGE): v$NEW_VERSION" - -if git -C "$ROOT" remote get-url origin >/dev/null 2>&1; then - git -C "$ROOT" push -q origin HEAD --follow-tags +if [ "$PACKAGE" = "commands" ] && [ "$CURRENT_CONFIG" != "$NEW_VERSION" ]; then + sed -i "s/const VERSION = '[^']*'/const VERSION = '$NEW_VERSION'/" "$CONFIG_PHP" fi -echo "release $PACKAGE v$NEW_VERSION ($TAG)" +git -C "$ROOT" add -- "$MANIFEST" +[ "$PACKAGE" = "commands" ] && git -C "$ROOT" add -- "$CONFIG_PHP" +git -C "$ROOT" commit -q -m "release($PACKAGE): v$NEW_VERSION" +git -C "$ROOT" tag -a "$TAG" -m "release($PACKAGE): v$NEW_VERSION" +echo "OK: commit + tag $TAG creados." + +if git -C "$ROOT" remote get-url origin >/dev/null 2>&1; then + if confirm "Pushear a origin (commit + tag)?"; then + git -C "$ROOT" push -q origin HEAD --follow-tags + echo "OK: pusheado." + else + echo "Sin pushear. Cuando quieras: git push origin HEAD --follow-tags" + fi +else + echo "No hay remote origin; el tag queda local." +fi diff --git a/tests/group8_project_cli_test.php b/tests/group8_project_cli_test.php index 96ffbb6..a214ea0 100644 --- a/tests/group8_project_cli_test.php +++ b/tests/group8_project_cli_test.php @@ -61,12 +61,12 @@ Harness::it('la actualización del motor surte efecto en la siguiente invocació // Simula una actualización del motor reescribiendo su archivo en disco. $config = (string) file_get_contents($proj . '/src/Toolbox/Config.php'); - $config = str_replace("VERSION = '0.1.0'", "VERSION = '0.9.9'", $config); + $config = preg_replace("/VERSION = '[^']*'/", "VERSION = '0.9.9'", $config); write_file($proj . '/src/Toolbox/Config.php', $config); $after = trim((string) shell_exec('cd ' . escapeshellarg($proj) . ' && php duckbrain --version 2>&1')); - ok(str_contains($before, '0.1.0'), 'antes usa la versión vieja'); + ok(str_contains($before, \Toolbox\Config::VERSION), 'antes usa la versión vieja'); ok(str_contains($after, '0.9.9'), 'después usa la versión nueva sin recompilar'); rrmdir($proj);