Files
gstack/test/bun-resolve.test.ts
T
Garry Tanandcapy-ai[bot] 83f15043ac v1.91.110.0 fix(capy): install the project's pinned Node; launch Bun through a bun-only directory (#3233)
* fix(capy): install and launch the project's pinned Node; launch Bun through a bun-only directory

gbrain pins node 24.18.0 in CI while the Capy image ships 24.21.0 in
/usr/local/bin, so a full install still ended with fail pins (GBRA-117).
The installer now resolves the project's exact Node pins the way it resolves
Bun's (required non-CI pins, CI pins in different jobs as alternatives): the
node first on PATH, then gstack's store copy and nvm's copy of each wanted
version, then any other node on PATH. With no match, install mode downloads the
pinned release into <state root>/runtimes/node and checks it against
SHASUMS256.txt. The resolution is recorded in the install verdict
(node_path, node_version, node_rule), and --check reuses it.

The launch PATH no longer prepends a shared bin directory. When the resolved
Bun was /usr/local/bin/bun, the image's node there shadowed every other node
(GBRA-122). A store Bun still launches from its own version directory; any
other Bun, and a Node the launch needs, launch through a gstack-owned
directory of symlinks beside the verdict. The pregate's lanes use the
recorded launch directory.

The doctor's pins row names the node it read and, for a Node mismatch, the
installer command that installs the pinned Node (or nvm install <v> off
Capy).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: v1.91.109.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(egress): exempt the Node installer's two release downloads with their reason

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: restamp v1.91.110.0 (1.91.109.0 is #3225's slot)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(doctor): make the Node pin case platform-neutral (an any-lane CI pin, bash's spelling of the stub path)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 00:41:41 +00:00

142 lines
8.5 KiB
TypeScript

/**
* The one Bun resolution contract (plan A1, A-d): `gstack_bun_resolve` in
* bin/gstack-bun-version.sh and `resolveBun` / `launchRuntime` in
* lib/pregate/runtime.ts must pick the same binary (by realpath) for the
* GBRA-60 layout: the image's old Bun first on PATH, Bun 1.4.2 in
* $BUN_INSTALL/bin, Bun 1.4.3 elsewhere, a project pinning 1.4.3 only through
* a CI lane, an unpinned project and a directory outside any project. A file
* carrying the shim marker is never returned, and a candidate is chosen only
* when it satisfies every applicable pin.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BUN_SHIM_MARKER, bunStore, launchRuntime, projectBunPins, resolveBun } from '../lib/pregate/runtime';
const ROOT = path.resolve(import.meta.dir, '..');
const REAL_BUN = fs.realpathSync(process.execPath);
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
const fakeBun = (file: string, version: string) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `#!/usr/bin/env bash\n[ "$1" = --version ] && { echo ${version}; exit 0; }\nexec "${REAL_BUN}" "$@"\n`, { mode: 0o755 });
return file;
};
function layout(opts: { storeVersions?: string[]; homeVersion?: string } = {}) {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bun-resolve-')));
bases.push(base);
const home = path.join(base, 'home'), state = path.join(home, '.gstack');
const image = fakeBun(path.join(base, 'usr-local-bin/bun'), '1.3.14');
const bunInstall = path.join(base, 'bun-install');
fakeBun(path.join(bunInstall, 'bin/bun'), '1.4.2');
for (const v of opts.storeVersions ?? []) fakeBun(path.join(bunStore(state), v, 'bun'), v);
if (opts.homeVersion) fakeBun(path.join(home, '.bun/bin/bun'), opts.homeVersion);
const shimDir = path.join(home, '.local/bin');
fs.mkdirSync(shimDir, { recursive: true });
fs.writeFileSync(path.join(shimDir, 'bun'), `#!/bin/sh\n${BUN_SHIM_MARKER}\nexec ${image} "$@"\n`, { mode: 0o755 });
const pinned = path.join(base, 'gbrain');
fs.mkdirSync(path.join(pinned, '.github/workflows'), { recursive: true });
fs.writeFileSync(path.join(pinned, 'package.json'), '{ "name": "gbrain", "engines": { "bun": ">=1.4.0" } }\n');
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.3\n');
const unpinned = path.join(base, 'plain');
fs.mkdirSync(unpinned);
fs.writeFileSync(path.join(unpinned, 'package.json'), '{ "name": "plain" }\n');
for (const d of [pinned, unpinned]) spawnSync('git', ['init', '-q', d], { timeout: 30_000 });
const outside = path.join(base, 'tmp');
fs.mkdirSync(outside);
const env = { HOME: home, BUN_INSTALL: bunInstall, PATH: `${shimDir}:${path.dirname(image)}:/usr/bin:/bin`, GSTACK_STATE_ROOT: state };
return { base, home, state, bunInstall, pinned, unpinned, outside, env };
}
/** The Bash twin with the installer's project-pin hook: the doctor's parser, run by a real Bun. */
function bashResolve(l: ReturnType<typeof layout>, project: string | null): string {
const script = `
. "$R/bin/gstack-bun-version.sh"
PIN_EXACT=""
if [ -n "$P" ]; then
PIN_EXACT=$("$RB" "$R/bin/gstack-runtime-pins.ts" --project "$P" | sed -n 's/^PIN: bun \\([^ ]*\\) exact [^ ]* lane=\\(any\\|linux\\)$/\\1/p' | sort -u)
[ "$(printf '%s\\n' "$PIN_EXACT" | grep -c .)" -le 1 ] || PIN_EXACT=""
fi
accept() { [ -z "$P" ] || "$RB" "$R/bin/gstack-runtime-pins.ts" --project "$P" --bun "$1" | grep -Eq '^PINS_VERDICT: (pass|none)$'; }
gstack_bun_resolve "$(gstack_bun_store "$GSTACK_STATE_ROOT")" "$PIN_EXACT" accept && printf '%s %s %s\\n' "$_gb_bin" "$_gb_ver" "$_gb_rule"`;
const r = spawnSync('bash', ['-c', script], { cwd: project ?? l.outside, encoding: 'utf8', timeout: 60_000, env: { ...l.env, R: ROOT, RB: REAL_BUN, P: project ?? '' } });
return r.stdout.trim();
}
function tsResolve(l: ReturnType<typeof layout>, project: string | null): string {
const { pinExact, accept } = projectBunPins(project);
const { resolution } = resolveBun({ store: bunStore(l.state), env: l.env, pinExact, accept });
return resolution ? `${resolution.bin} ${resolution.version} ${resolution.rule}` : '';
}
describe('one Bun resolution contract, two twins', () => {
test('GBRA-60: 1.4.3 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.3 — both twins pick the store 1.4.3 for the project and for an unpinned or outside directory', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const store143 = path.join(bunStore(l.state), '1.4.3/bun');
for (const [project, want] of [[l.pinned, `${store143} 1.4.3 pin`], [l.unpinned, `${store143} 1.4.3 default`], [null, `${store143} 1.4.3 default`]] as const) {
expect(bashResolve(l, project)).toBe(want);
expect(tsResolve(l, project)).toBe(want);
}
const lanes = launchRuntime(l.pinned, l.env);
expect(path.join(lanes.dir!, 'bun')).toBe(store143);
expect(lanes.verdict).toBe('skipped');
expect(lanes.label).toContain('install verdict: not checked');
});
test('1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
const l = layout({ homeVersion: '1.4.3' });
const home143 = path.join(l.home, '.bun/bin/bun');
const bi142 = path.join(l.bunInstall, 'bin/bun');
for (const [project, want] of [[l.pinned, `${home143} 1.4.3 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
expect(bashResolve(l, project)).toBe(want);
expect(tsResolve(l, project)).toBe(want);
}
});
test('no eligible candidate: both twins return nothing (the shim and the old image Bun are never chosen)', () => {
const l = layout();
fs.rmSync(path.join(l.bunInstall, 'bin/bun'));
expect(bashResolve(l, l.pinned)).toBe('');
expect(tsResolve(l, l.pinned)).toBe('');
expect(launchRuntime(l.pinned, l.env).dir).toBeNull();
});
test('a symlinked candidate resolves to its target realpath in both twins (portable readlink loop, no realpath(1))', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const linkDir = path.join(l.base, 'links');
fs.mkdirSync(linkDir);
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.3/bun', path.join(linkDir, 'bun'));
fs.rmSync(path.join(l.bunInstall, 'bin/bun'));
const env = { ...l.env, GSTACK_STATE_ROOT: path.join(l.base, 'elsewhere'), PATH: `${linkDir}:${l.env.PATH}` };
const want = `${path.join(bunStore(l.state), '1.4.3/bun')} 1.4.3 PATH`;
expect(bashResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
expect(tsResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
});
// Integration item 8: the gstack checkout itself binds the 'none' verdict.
test('launchRuntime on the gstack checkout reads the none verdict and launches its recorded Bun', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
const checkout = fs.realpathSync(ROOT);
const file = installVerdictPath(checkout, l.state, null);
const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ schema_version: 2, checkout, project: '', head, for: 'ship', required: ['claude', 'codex', 'patch'], pins_planning_only: false, ok: true, trailer: 'gstack: ok', min_revision: '', launch_path: '', time: '', bun_path: REAL_BUN, bun_version: Bun.version }));
const r = launchRuntime(ROOT, l.env);
expect(r.refusal).toBeNull();
expect(r.verdict).toBe('ok');
expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`);
expect(r.dir).toBe(path.dirname(REAL_BUN));
const launch = path.join(l.state, 'install-verdicts', 'x.launch');
fs.mkdirSync(launch, { recursive: true });
fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), launch_path: launch }));
const viaLaunch = launchRuntime(ROOT, l.env);
expect(viaLaunch.dir).toBe(launch);
expect(viaLaunch.env.PATH?.split(path.delimiter)[0]).toBe(launch);
});
});