From 9c4635b221b17900c96711205b092243896ef30c Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 15 Oct 2026 00:27:56 +0000 Subject: [PATCH] fix(upgrade): every success path certifies the caller's project; a pins failure keeps the move, ends on gstack: fail pins, exit 1, no pending marker (A7, A-b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gstack-upgrade captures the caller's directory and git toplevel before any cd. Already-current, fast-forwarded and resumed-pending paths run bin/gstack-capy-install --project on Capy (installing a verified pinned Bun) or gstack-doctor --check --require runtime,pins elsewhere. The reinstall's own trailer is held back when a project certification follows, so no ok precedes a pins failure; a RUNTIME_PIN_CONFLICT stop becomes gstack: fail pins — . --check and --ff-only-no-reinstall certify nothing. Files: bin/gstack-upgrade, test/gstack-upgrade-bin.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-upgrade | 63 +++++++++++++++++++----- test/gstack-upgrade-bin.test.ts | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index 1cc48cc20..7505c8a55 100755 --- a/bin/gstack-upgrade +++ b/bin/gstack-upgrade @@ -24,18 +24,31 @@ # lease on this installation (bin/gstack-lease live) it refuses with # UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints # `gstack-upgrade: upgraded -> ` before reinstalling; a failed -# reinstall exits 1 and the next run completes it. +# reinstall exits 1 and the next run completes it (a pending marker in the git +# dir, kept only for setup or install failures). +# +# Every success path (already current, fast-forwarded, a completed pending +# reinstall) then certifies the caller's project: the git toplevel of the +# directory gstack-upgrade was started in, captured before any cd. On Capy +# that is bin/gstack-capy-install --project (it installs the project's +# pinned Bun when that pin is a verified version); elsewhere gstack-doctor +# --check --require runtime,pins. A failure prints the standard trailer +# (`gstack: fail pins`) and exits 1; the checkout move is kept and no pending +# marker is left. Outside a project, or with --check, nothing is certified. # Bash 3.2 plus git; the fetch is bounded by `timeout` when present. set -u BASH_COMPAT=50 ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +CALLER=$(pwd -P) +CALLER_PROJECT=$(git rev-parse --show-toplevel 2>/dev/null) && CALLER_PROJECT=$(cd "$CALLER_PROJECT" && pwd -P) || CALLER_PROJECT="" +[ "$CALLER_PROJECT" = "$ROOT" ] && CALLER_PROJECT="" CHECK=0 REINSTALL=1 case "${1:-}" in --check) CHECK=1 ;; --ff-only-no-reinstall) REINSTALL=0 ;; '') ;; - -h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;; esac @@ -62,16 +75,41 @@ _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$ INCOMING=$(git -C "$ROOT" rev-parse FETCH_HEAD) REMOTE_VER=$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) PENDING="$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-upgrade.pending" -reinstall() { # FROM — run the install step; a pending marker survives a failure - printf '%s\n' "$1" > "$PENDING" - cd "$ROOT" || exit 1 - if [ -f "$(git rev-parse --absolute-git-dir 2>/dev/null)/gstack-capy-install.state" ] && [ -x "$ROOT/bin/gstack-capy-install" ]; then - GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" "$_err") + _rc=$? + _conflict=$(grep '(RUNTIME_PIN_CONFLICT;' "$_err" | tail -1) + cat "$_err" >&2; rm -f "$_err" + [ "$_rc" -eq 0 ] && exit 0 + [ -n "$_conflict" ] && echo "gstack: fail pins — ${_conflict#gstack-capy-install: }" + exit 1 fi - _rc=$? - [ "$_rc" -eq 0 ] && { rm -f "$PENDING"; exit 0; } + (cd "$CALLER" && "$ROOT/bin/gstack-doctor" --check --require runtime,pins --project "$CALLER_PROJECT" "$PENDING" + _held=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") + if [ "$CAPY" -eq 1 ]; then + (cd "$ROOT" && GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" "$_held"; continue; } ;; esac + printf '%s\n' "$_line" + done + _rc=${PIPESTATUS[0]} + else + (cd "$ROOT" && ./setup --refresh-registered &2 exit 1 } @@ -82,7 +120,8 @@ if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge- reinstall "$(head -1 "$PENDING")" fi echo "gstack-upgrade: current $INSTALLED" - exit 0 + [ "$CHECK" -eq 1 ] && exit 0 + certify fi if [ "$CHECK" -eq 1 ]; then echo "gstack-upgrade: behind $INSTALLED -> ${REMOTE_VER:-unknown} ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits)" diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index 13c97b6d3..c1e92da26 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -232,6 +232,91 @@ describe('gstack-upgrade', () => { expect(again.stdout).toContain('gstack-upgrade: current 1.0.1'); }); + // Plan A7/A-b: every success path certifies the caller's project. + const STUB_CAPY = [ + '#!/usr/bin/env bash', + 'echo "capy-install $* cwd=$(pwd -P)" >> "$UPGRADE_LOG"', + 'case " $* " in *" --project "*)', + ' [ -n "${STUB_PIN_CONFLICT:-}" ] && { echo "gstack-capy-install: Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), $2/package.json pins exactly 1.3.14; fix: raise the project\'s pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)" >&2; exit 3; }', + ' [ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — bun 1.4.2 outside CI 1.4.3; fix: x (PROJECT_PIN_MISMATCH; d)"; echo "gstack: fail pins"; exit 1; } ;;', + 'esac', + '[ -f "${STUB_FAIL_ONCE:-/nonexistent}" ] && { rm -f "$STUB_FAIL_ONCE"; echo "gstack: fail setup — ./setup failed"; exit 1; }', + 'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"', + ].join('\n') + '\n'; + const capyFixture = () => { + const f = fixture(); + write(path.join(f.root, 'bin/gstack-capy-install'), STUB_CAPY, 0o755); + write(path.join(f.root, '.git/gstack-capy-install.state'), `head=${f.a}\n`); + const project = path.join(f.host.base, 'project'); + fs.mkdirSync(project, { recursive: true }); + spawnSync('git', ['init', '-q', project], { timeout: 30_000 }); + const from = (cwd: string, args: string[] = [], extra: Record = {}) => + spawnSync('bash', [path.join(f.root, 'bin/gstack-upgrade'), ...args], { cwd, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), ...extra }, timeout: 60_000 }); + return { ...f, project: fs.realpathSync(project), from, pending: path.join(f.root, '.git/gstack-upgrade.pending') }; + }; + + test('A-b: from a RUNTIME_PIN_CONFLICT project the move is kept, `upgraded` then `gstack: fail pins`, exit 1, no pending marker; the no-op path says the same', () => { + const f = capyFixture(); + const r = f.from(f.project, [], { STUB_PIN_CONFLICT: '1' }); + expect(r.status, r.stdout + r.stderr).toBe(1); + const lines = r.stdout.trim().split('\n'); + expect(lines[0]).toBe('gstack-upgrade: upgraded 1.0.0 -> 1.0.1'); + expect(lines.at(-1)).toBe(`gstack: fail pins — Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), ${f.project}/package.json pins exactly 1.3.14; fix: raise the project's pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)`); + expect(r.stdout).not.toContain('gstack: ok'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + expect(fs.existsSync(f.pending)).toBe(false); + expect(f.calls()).toEqual([`capy-install cwd=${f.root}`, `capy-install --project ${f.project} cwd=${f.project}`]); + const again = f.from(f.project, [], { STUB_PIN_CONFLICT: '1' }); + expect(again.status).toBe(1); + expect(again.stdout.trim().split('\n')[0]).toBe('gstack-upgrade: current 1.0.1'); + expect(again.stdout.trim().split('\n').at(-1)).toMatch(/^gstack: fail pins — Bun conflict: /); + expect(f.from(f.project, ['--check'], { STUB_PIN_CONFLICT: '1' }).status).toBe(0); + }); + + test('a verified-pin project ends on the installer\'s ok for that project; a pins failure ends on its `gstack: fail pins`; from /tmp nothing is certified', () => { + const f = capyFixture(); + const ok = f.from(f.project); + expect(ok.status, ok.stdout + ok.stderr).toBe(0); + expect(ok.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan'); + expect(ok.stdout.match(/^gstack: /gm)).toHaveLength(1); + const fail = f.from(f.project, [], { STUB_PINS_FAIL: '1' }); + expect(fail.status).toBe(1); + expect(fail.stdout.trim().split('\n').at(-1)).toBe('gstack: fail pins'); + expect(fs.existsSync(f.pending)).toBe(false); + const g = capyFixture(); + const outside = g.from(fs.realpathSync(g.host.base), [], { STUB_PINS_FAIL: '1' }); + expect(outside.status, outside.stdout + outside.stderr).toBe(0); + expect(g.calls()).toEqual([`capy-install cwd=${g.root}`]); + expect(outside.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan'); + }); + + test('a pending repair resumed from another project certifies that project', () => { + const f = capyFixture(); + const once = path.join(f.host.base, 'fail-once'); + write(once, ''); + const failed = f.from(fs.realpathSync(f.host.base), [], { STUB_FAIL_ONCE: once }); + expect(failed.status).toBe(1); + expect(failed.stdout).toContain('gstack: fail setup'); + expect(fs.existsSync(f.pending)).toBe(true); + const resumed = f.from(f.project); + expect(resumed.status, resumed.stdout + resumed.stderr).toBe(0); + expect(resumed.stdout).toContain('gstack-upgrade: current 1.0.1; reinstall pending from 1.0.0'); + expect(fs.existsSync(f.pending)).toBe(false); + expect(f.calls().at(-1)).toBe(`capy-install --project ${f.project} cwd=${f.project}`); + }); + + test('off Capy the caller\'s project is certified by the doctor\'s runtime and pins rows', () => { + const f = fixture(); + write(path.join(f.root, 'bin/gstack-doctor'), '#!/usr/bin/env bash\necho "doctor $*" >> "$UPGRADE_LOG"\n[ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — x"; echo "gstack: fail pins"; exit 1; }\necho "gstack: ok 1.0.1 for=require=runtime,pins"\n', 0o755); + const project = path.join(f.host.base, 'project'); + fs.mkdirSync(project); + spawnSync('git', ['init', '-q', project], { timeout: 30_000 }); + const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-upgrade')], { cwd: project, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), STUB_PINS_FAIL: '1' }, timeout: 60_000 }); + expect(r.status).toBe(1); + expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack: fail pins'); + expect(f.calls()).toEqual(['setup --refresh-registered', `doctor --check --require runtime,pins --project ${fs.realpathSync(project)}`]); + }); + test('every bin a trailer or fix line names exists in the repo', () => { const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md', 'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];