From 10ab8559b94aef24a393d00a617b2e75ad8560b4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 4 Oct 2026 18:31:33 +0000 Subject: [PATCH] E1: /gstack-upgrade checks the incoming Bun floor before pulling gstack_bun_incoming_hold (bin/gstack-bun-version.sh) is now the one pre-advance check: the auto-updater calls it before its fast-forward and /gstack-upgrade's git path calls it after fetching. Below the incoming release's floor the upgrade stops with BUN_TOO_OLD and the checkout, VERSION and installs stay untouched. Vendored installs were already safe: setup refuses before writing and the swap restores the previous install. --- bin/gstack-bun-version.sh | 21 ++++++++- bin/gstack-session-update | 9 +--- gstack-upgrade/SKILL.md.tmpl | 3 ++ test/upgrade-setup-recovery.test.ts | 69 +++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index eeb66e978..2b50e8ea0 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -1,8 +1,9 @@ # shellcheck shell=bash # gstack-bun-version.sh — the one source for the Bun versions gstack needs. # Sourced, never executed: ./setup refuses or warns from it before writing -# anything, and bin/gstack-session-update reads the INCOMING release's copy -# (git show) before advancing a live checkout. test/bun-version-drift.test.ts +# anything, and bin/gstack-session-update and /gstack-upgrade read the +# INCOMING release's floor (gstack_bun_incoming_hold) before advancing a +# live checkout. test/bun-version-drift.test.ts # keeps it equal to package.json engines.bun, every CI pin, setup's install # hint and README. Bash 3.2 builtins only. # @@ -56,3 +57,19 @@ gstack_bun_status() { else echo ok fi } + +# gstack_bun_incoming_hold — the pre-advance check shared by +# the auto-updater and /gstack-upgrade. Reads the floor from 's copy of +# this file and checks the `bun` setup will run (first on PATH). Below that +# floor it prints the held reason and returns 0; otherwise it prints nothing +# and returns 1 (no bun, no floor in , or an unparseable version, which +# setup only warns on). The comparison rule is this release's. +gstack_bun_incoming_hold() { + command -v bun >/dev/null 2>&1 || return 1 + _gb_floor=$(git -C "$1" show "$2:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_FLOOR="\([0-9A-Za-z.+-]*\)".*/\1/p' | head -1) + [ -n "$_gb_floor" ] || return 1 + _gb_found=$(bun --version 2>/dev/null | head -1) + [ "$(gstack_bun_status "$_gb_found" "$_gb_floor")" = too-old ] || return 1 + _gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1) + echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer" +} diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 53d5d1fb2..259ec2aa9 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -192,13 +192,8 @@ fi if [ "$PULL_EXIT" -eq 0 ]; then INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 fi - if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ] && command -v bun >/dev/null 2>&1; then - _IN_FLOOR=$(git -C "$GSTACK_DIR" show "$INCOMING:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_FLOOR="\([0-9A-Za-z.+-]*\)".*/\1/p' | head -1) - _BUN_FOUND=$(bun --version 2>/dev/null | head -1) - if [ -n "$_IN_FLOOR" ] && [ "$(gstack_bun_status "$_BUN_FOUND" "$_IN_FLOOR" 2>/dev/null)" = too-old ]; then - _IN_VERSION=$(git -C "$GSTACK_DIR" show "$INCOMING:VERSION" 2>/dev/null | head -1) - BUN_HELD="bun-too-old: found Bun $_BUN_FOUND at $(command -v bun); gstack ${_IN_VERSION:-update} needs $_IN_FLOOR or newer" - fi + if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ]; then + BUN_HELD=$(gstack_bun_incoming_hold "$GSTACK_DIR" "$INCOMING" 2>/dev/null) || BUN_HELD="" fi if [ "$PULL_EXIT" -eq 0 ] && [ -z "$BUN_HELD" ]; then git -C "$GSTACK_DIR" merge --ff-only --autostash -q "$INCOMING" >/dev/null 2>"${PULL_ERR_FILE:-/dev/null}" diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 296ce14cf..b7c80d17f 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -143,6 +143,8 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p # re-renders to ~/.gstack/render), so discarding is lossless. git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true git fetch origin +# Same pre-advance check as the auto-updater: the incoming release's Bun floor. +HOLD=$(. bin/gstack-bun-version.sh 2>/dev/null && gstack_bun_incoming_hold . origin/main) && { echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" if git pull --ff-only --autostash origin main; then @@ -161,6 +163,7 @@ belongs to another checkout or project; pass on its command, do not run it. If the output ends with `FF_OK`, the upgrade is done — skip the fallback below entirely. +On `BUN_TOO_OLD`, STOP: tell the user to run `bun upgrade`, then /gstack-upgrade again. On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED`, after inspecting the pull error; network/auth failures stop for repair, not reset. **Fallback (ff-only refused — local commits or divergence).** `git reset diff --git a/test/upgrade-setup-recovery.test.ts b/test/upgrade-setup-recovery.test.ts index d0dfe7f15..9ee0f5c8f 100644 --- a/test/upgrade-setup-recovery.test.ts +++ b/test/upgrade-setup-recovery.test.ts @@ -68,3 +68,72 @@ describe.skipIf(process.platform === 'win32')('upgrade setup recovery (real shel } }); }); + +// /gstack-upgrade checks the incoming release's Bun floor before pulling, with +// the auto-updater's helper: below it the checkout and installs stay untouched. +describe.skipIf(process.platform === 'win32')('upgrade Bun floor (real git)', () => { + const git = (cwd: string, ...args: string[]) => + spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + + function fixture(floor: string, bunVersion: string) { + const root = mkdtempSync(join(tmpdir(), 'upgrade-bun-floor-')); + const origin = join(root, 'origin.git'); + const seed = join(root, 'seed'); + const install = join(root, 'install'); + const stub = join(root, 'stub'); + for (const dir of [join(seed, 'bin'), stub]) mkdirSync(dir, { recursive: true }); + spawnSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + const helper = readFileSync(join(import.meta.dir, '../bin/gstack-bun-version.sh'), 'utf8'); + writeFileSync(join(seed, 'bin', 'gstack-bun-version.sh'), helper); + writeFileSync(join(seed, 'bin', 'gstack-config'), '#!/bin/sh\n', { mode: 0o755 }); + writeFileSync(join(seed, 'VERSION'), '1.0.0\n'); + writeFileSync(join(seed, 'setup'), '#!/bin/sh\necho ran >> "$SETUP_LOG"\n', { mode: 0o755 }); + git(seed, 'init', '-q', '-b', 'main'); + git(seed, 'add', '-A'); + git(seed, 'commit', '-qm', 'seed'); + git(seed, 'remote', 'add', 'origin', origin); + git(seed, 'push', '-q', 'origin', 'main'); + spawnSync('git', ['clone', '-q', origin, install], { timeout: 30_000 }); + writeFileSync(join(seed, 'bin', 'gstack-bun-version.sh'), + helper.replace(/^GSTACK_BUN_FLOOR="[^"]*"/m, `GSTACK_BUN_FLOOR="${floor}"`)); + writeFileSync(join(seed, 'VERSION'), '1.1.0\n'); + git(seed, 'commit', '-aqm', 'release 1.1.0'); + git(seed, 'push', '-q', 'origin', 'main'); + writeFileSync(join(stub, 'bun'), `#!/bin/sh\necho ${bunVersion}\n`, { mode: 0o755 }); + const before = git(install, 'rev-parse', 'HEAD'); + const result = spawnSync('bash', ['-c', blockAfter('**For git installs**')], { + cwd: root, encoding: 'utf8', timeout: 30_000, + env: { ...process.env, PATH: `${stub}:${process.env.PATH}`, INSTALL_DIR: install, SETUP_LOG: join(root, 'setup.log') }, + }); + return { root, install, stub, before, result, after: git(install, 'rev-parse', 'HEAD'), + setupRan: existsSync(join(root, 'setup.log')), version: readFileSync(join(install, 'VERSION'), 'utf8') }; + } + + test('below the incoming floor: stops before the pull with the held reason', () => { + const fx = fixture('9.0.0', '1.4.0'); + try { + expect(fx.result.status).toBe(1); + expect(fx.result.stderr).toContain( + `BUN_TOO_OLD: bun-too-old: found Bun 1.4.0 at ${join(fx.stub, 'bun')}; gstack 1.1.0 needs 9.0.0 or newer; nothing was changed`); + expect(fx.result.stdout).not.toContain('FF_OK'); + expect(fx.after).toBe(fx.before); + expect(fx.version).toBe('1.0.0\n'); + expect(fx.setupRan).toBe(false); + } finally { + rmSync(fx.root, { recursive: true, force: true }); + } + }); + + test('at or above the incoming floor: the fast-forward and setup run', () => { + const fx = fixture('1.3.3', '1.4.0'); + try { + expect(fx.result.status, fx.result.stderr).toBe(0); + expect(fx.result.stdout).toContain('FF_OK'); + expect(fx.after).not.toBe(fx.before); + expect(fx.version).toBe('1.1.0\n'); + expect(fx.setupRan).toBe(true); + } finally { + rmSync(fx.root, { recursive: true, force: true }); + } + }); +});