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)

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 <it> 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 — <conflict>. --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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-15 00:27:56 +00:00
1 parent 266ebb1301
commit 9c4635b221
2 files changed
+136 -12

No files matched your search

+51 -12
View File
@@ -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 <old> -> <new>` 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> (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" </dev/null
else
./setup --refresh-registered </dev/null
CAPY=0
[ -f "$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-capy-install.state" ] && [ -x "$ROOT/bin/gstack-capy-install" ] && CAPY=1
# certify — the caller's project (see the header); exits with its outcome.
certify() {
[ -n "$CALLER_PROJECT" ] && [ "$REINSTALL" -eq 1 ] || exit 0
if [ "$CAPY" -eq 1 ]; then
_err=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX")
(cd "$CALLER" && "$ROOT/bin/gstack-capy-install" --project "$CALLER_PROJECT" </dev/null 2>"$_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" </dev/null) && exit 0
exit 1
}
reinstall() { # FROM — run the install step; a pending marker survives a failure.
# With a project to certify next, the installer's own trailer is held back
# (printed only when the reinstall fails), so no ok precedes a pins failure.
printf '%s\n' "$1" > "$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" </dev/null) | while IFS= read -r _line; do
case "$_line" in "gstack: "*) [ -n "$CALLER_PROJECT" ] && { printf '%s\n' "$_line" > "$_held"; continue; } ;; esac
printf '%s\n' "$_line"
done
_rc=${PIPESTATUS[0]}
else
(cd "$ROOT" && ./setup --refresh-registered </dev/null)
_rc=$?
fi
if [ "$_rc" -eq 0 ]; then rm -f "$PENDING" "$_held"; certify; fi
cat "$_held"; rm -f "$_held"
echo "gstack-upgrade: the checkout is at $(head -1 "$ROOT/VERSION") but its reinstall failed (exit $_rc); fix: re-run $ROOT/bin/gstack-upgrade (it completes the pending reinstall)" >&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)"
+85
View File
@@ -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<string, string> = {}) =>
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'];