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.
This commit is contained in:
garrytan committed 2026-10-04 18:31:33 +00:00
1 parent ee8736f656
commit 10ab8559b9
4 files changed
+93 -9

No files matched your search

+19 -2
View File
@@ -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 <git-dir> <rev> — the pre-advance check shared by
# the auto-updater and /gstack-upgrade. Reads the floor from <rev>'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 <rev>, 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"
}
+2 -7
View File
@@ -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}"
+3
View File
@@ -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
+69
View File
@@ -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 });
}
});
});