mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(ci): bounded Playwright install with one infra retry, PR runs get main's local branch, Bun install default 1.4.4 (GSTA-68) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(runtime-pins): read the install-default job's pin from GSTACK_BUN_DEFAULT (GSTA-68) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.189.0 stamp 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>
142 lines
8.5 KiB
TypeScript
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.4 elsewhere, a project pinning 1.4.4 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.4\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.4 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.4 — both twins pick the store 1.4.4 for the project and for an unpinned or outside directory', () => {
|
|
const l = layout({ storeVersions: ['1.4.4'] });
|
|
const store144 = path.join(bunStore(l.state), '1.4.4/bun');
|
|
for (const [project, want] of [[l.pinned, `${store144} 1.4.4 pin`], [l.unpinned, `${store144} 1.4.4 default`], [null, `${store144} 1.4.4 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(store144);
|
|
expect(lanes.verdict).toBe('skipped');
|
|
expect(lanes.label).toContain('install verdict: not checked');
|
|
});
|
|
|
|
test('1.4.2 in $BUN_INSTALL and 1.4.4 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.4' });
|
|
const home144 = path.join(l.home, '.bun/bin/bun');
|
|
const bi142 = path.join(l.bunInstall, 'bin/bun');
|
|
for (const [project, want] of [[l.pinned, `${home144} 1.4.4 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.4'] });
|
|
const linkDir = path.join(l.base, 'links');
|
|
fs.mkdirSync(linkDir);
|
|
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.4/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.4/bun')} 1.4.4 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.4'] });
|
|
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);
|
|
});
|
|
});
|