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>
222 lines
12 KiB
TypeScript
222 lines
12 KiB
TypeScript
/**
|
|
* lib/runtime-pins.ts: the doctor's `project pins` row. Pins come from
|
|
* package.json, .tool-versions, .nvmrc, .bun-version and CI workflows with
|
|
* their file:line; a range and an exact pin are told apart; a CI pin keeps
|
|
* its platform lane and never fails another platform; an unevaluable value is
|
|
* reported, not dropped. The bin prints PINS_VERDICT last and exits 1 on fail.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { classify, collectPins, evaluatePins, renderPinLines, satisfies } from '../lib/runtime-pins';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BUN_DEFAULT = fs.readFileSync(path.join(ROOT, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
|
|
const dirs: string[] = [];
|
|
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
|
|
|
|
function project(files: Record<string, string>): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-pins-'));
|
|
dirs.push(dir);
|
|
for (const [rel, text] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, rel), text);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
const WORKFLOW = [
|
|
'name: ci', 'on: push', 'jobs:',
|
|
' linux:', ' runs-on: ubuntu-latest', ' steps:',
|
|
' - uses: oven-sh/setup-bun@v2', ' with:', ' bun-version: 1.4.2',
|
|
' windows:', ' runs-on: windows-latest', ' steps:',
|
|
' - uses: oven-sh/setup-bun@v2', ' with:', " bun-version: '1.4.2'",
|
|
' - uses: actions/setup-node@v7', ' with:', ' node-version: 24.18.0',
|
|
' matrix:', ' runs-on: ${{ matrix.os }}', ' steps:',
|
|
' - uses: oven-sh/setup-bun@v2', ' with:', ' bun-version: ${{ matrix.bun }}',
|
|
'',
|
|
].join('\n');
|
|
|
|
describe('satisfies and classify', () => {
|
|
test('ranges, exact pins, partial versions, wildcards and alternatives', () => {
|
|
expect(satisfies('1.3.14', '>=1.4.2')).toBe(false);
|
|
expect(satisfies('1.4.2', '>=1.4.2')).toBe(true);
|
|
expect(satisfies('1.4.5', '1.4.2')).toBe(false);
|
|
expect(satisfies('1.4.2', '1.4.2')).toBe(true);
|
|
expect(satisfies('24.18.0', '24')).toBe(true);
|
|
expect(satisfies('25.0.0', '24')).toBe(false);
|
|
expect(satisfies('24.18.0', '24.x')).toBe(true);
|
|
expect(satisfies('24.18.0', '^24.1.0')).toBe(true);
|
|
expect(satisfies('24.18.0', '~24.17.0')).toBe(false);
|
|
expect(satisfies('20.0.0', '>=18 <21')).toBe(true);
|
|
expect(satisfies('22.0.0', '>=18 <21 || >=22')).toBe(true);
|
|
expect(satisfies('1.0.0', '*')).toBe(true);
|
|
expect(satisfies('24.18.0', 'lts/*')).toBeNull();
|
|
expect(satisfies('24.18.0', '${{ matrix.node }}')).toBeNull();
|
|
expect(satisfies('garbage', '>=1')).toBeNull();
|
|
});
|
|
|
|
test('classify tells a range from an exact pin and names the unevaluable', () => {
|
|
expect(classify('>=1.4.2')).toBe('range');
|
|
expect(classify('1.4.2')).toBe('exact');
|
|
expect(classify('v1.4.2')).toBe('exact');
|
|
expect(classify('24')).toBe('range');
|
|
expect(classify('24.x')).toBe('range');
|
|
expect(classify('lts/*')).toBe('unevaluable');
|
|
});
|
|
});
|
|
|
|
describe('collectPins and evaluatePins', () => {
|
|
test('every source is read with file:line, CI pins carry their lane', () => {
|
|
const dir = project({
|
|
'package.json': '{\n "name": "x",\n "packageManager": "bun@1.4.2",\n "engines": {\n "bun": ">=1.4.2",\n "node": ">=22"\n }\n}\n',
|
|
'.tool-versions': 'nodejs 24.18.0\nbun 1.4.2\n',
|
|
'.nvmrc': 'lts/*\n',
|
|
'.bun-version': '1.4.2\n',
|
|
'.github/workflows/ci.yml': WORKFLOW,
|
|
});
|
|
const pins = collectPins(dir);
|
|
expect(pins.map(p => [p.tool, p.constraint, p.kind, p.source, p.field, p.lane])).toEqual([
|
|
['bun', '1.4.2', 'exact', 'package.json:3', 'packageManager', 'any'],
|
|
['bun', '>=1.4.2', 'range', 'package.json:5', 'engines.bun', 'any'],
|
|
['node', '>=22', 'range', 'package.json:6', 'engines.node', 'any'],
|
|
['node', '24.18.0', 'exact', '.tool-versions:1', '.tool-versions', 'any'],
|
|
['bun', '1.4.2', 'exact', '.tool-versions:2', '.tool-versions', 'any'],
|
|
['node', 'lts/*', 'unevaluable', '.nvmrc:1', '.nvmrc', 'any'],
|
|
['bun', '1.4.2', 'exact', '.bun-version:1', '.bun-version', 'any'],
|
|
['bun', '1.4.2', 'exact', '.github/workflows/ci.yml:9', 'CI', 'linux'],
|
|
['bun', '1.4.2', 'exact', '.github/workflows/ci.yml:15', 'CI', 'windows'],
|
|
['node', '24.18.0', 'exact', '.github/workflows/ci.yml:18', 'CI', 'windows'],
|
|
['bun', '${{ matrix.bun }}', 'unevaluable', '.github/workflows/ci.yml:24', 'CI', 'any'],
|
|
]);
|
|
});
|
|
|
|
test('a mismatch fails with the first source and the CI context; a Windows pin never blocks Linux', () => {
|
|
const dir = project({
|
|
'package.json': '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n',
|
|
'.github/workflows/ci.yml': WORKFLOW,
|
|
});
|
|
const linux = evaluatePins(collectPins(dir), { bun: '1.3.14', node: '22.0.0' }, 'linux');
|
|
expect(linux.verdict).toBe('fail');
|
|
expect(linux.failures).toEqual(['bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3; CI linux lane, ci.yml:9 pins 1.4.2)']);
|
|
expect(linux.skipped).toEqual(['bun ${{ matrix.bun }} (.github/workflows/ci.yml:24) not evaluated']);
|
|
// node 22 differs from the Windows job's 24.18.0 pin, which does not apply on Linux.
|
|
expect(linux.failures.some(f => f.startsWith('node'))).toBe(false);
|
|
|
|
const windows = evaluatePins(collectPins(dir), { bun: '1.4.2', node: '22.0.0' }, 'win32');
|
|
expect(windows.verdict).toBe('fail');
|
|
expect(windows.failures).toEqual(['node 22.0.0 outside CI 24.18.0 (.github/workflows/ci.yml:18)']);
|
|
|
|
const ok = evaluatePins(collectPins(dir), { bun: '1.4.2', node: '24.18.0' }, 'linux');
|
|
expect(ok.verdict).toBe('pass');
|
|
expect(ok.failures).toEqual([]);
|
|
expect(ok.summary).toEqual([
|
|
'bun >=1.4.2 (engines.bun, package.json:3)',
|
|
'bun 1.4.2 (CI linux lane, ci.yml:9)',
|
|
'bun 1.4.2 (CI windows lane, ci.yml:15)',
|
|
'node 24.18.0 (CI windows lane, ci.yml:18)',
|
|
'bun ${{ matrix.bun }} (CI, ci.yml:24)',
|
|
]);
|
|
});
|
|
|
|
test('no pins is none, a missing runtime is skipped with a reason, and the wire lines end with the verdict', () => {
|
|
expect(evaluatePins(collectPins(project({ 'README.md': 'x' })), { bun: '1.4.2' }).verdict).toBe('none');
|
|
const dir = project({ '.nvmrc': '24\n' });
|
|
const report = evaluatePins(collectPins(dir), { bun: '1.4.2' }, 'linux');
|
|
expect(report.verdict).toBe('none');
|
|
expect(report.skipped).toEqual(['node 24 (.nvmrc:1): node not found on PATH']);
|
|
const lines = renderPinLines(report, 'linux');
|
|
expect(lines[0]).toBe('PIN: node 24 range .nvmrc:1 lane=any');
|
|
expect(lines.at(-1)).toBe('PINS_VERDICT: none');
|
|
});
|
|
|
|
test('a malformed package.json contributes no pins and does not throw', () => {
|
|
expect(collectPins(project({ 'package.json': '{ not json' }))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// Integration item 1: CI exact pins for this platform are alternatives (any
|
|
// one satisfies CI); non-CI pins stay required.
|
|
describe('CI exact pins are alternatives', () => {
|
|
const lane = (name: string, version: string, os = 'ubuntu-latest') => [` ${name}:`, ` runs-on: ${os}`, ' steps:', ' - uses: oven-sh/setup-bun@v2', ' with:', ` bun-version: ${version}`];
|
|
const workflow = (...jobs: string[][]) => ['jobs:', ...jobs.flat(), ''].join('\n');
|
|
|
|
test('the real gstack checkout (CI 1.4.2 lanes plus the install-default job) passes on 1.4.2 and on the install default, and names the alternatives on 1.4.1', () => {
|
|
const pins = collectPins(ROOT);
|
|
const ci = [...new Set(pins.filter(p => p.tool === 'bun' && p.field === 'CI' && p.kind === 'exact' && p.lane !== 'windows' && p.lane !== 'macos').map(p => p.constraint))].sort();
|
|
expect(ci).toEqual(['1.4.2', BUN_DEFAULT]);
|
|
for (const bun of ['1.4.2', BUN_DEFAULT]) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]);
|
|
const low = evaluatePins(pins, { bun: '1.4.1' }, 'linux').failures.find(f => f.startsWith('bun '))!;
|
|
expect(low).toStartWith('bun 1.4.1 outside engines.bun >=1.4.2 (package.json:');
|
|
expect(low).toContain(`CI exact pins are alternatives, any one of 1.4.2, ${BUN_DEFAULT} satisfies CI`);
|
|
expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain(`PIN_CI_ALTERNATIVES: bun 1.4.2 ${BUN_DEFAULT}`);
|
|
});
|
|
|
|
test('gbrain-like (every CI lane 1.4.3) still fails a local 1.4.2 exactly as before', () => {
|
|
const dir = project({ '.github/workflows/ci.yml': workflow(lane('a', '1.4.3'), lane('b', '1.4.3')) });
|
|
const fail = evaluatePins(collectPins(dir), { bun: '1.4.2' }, 'linux');
|
|
expect(fail.verdict).toBe('fail');
|
|
expect(fail.failures).toEqual(['bun 1.4.2 outside CI 1.4.3 (.github/workflows/ci.yml:7)']);
|
|
expect(evaluatePins(collectPins(dir), { bun: '1.4.3' }, 'linux').verdict).toBe('pass');
|
|
});
|
|
|
|
test('GBRA-39, gbrain-evals-like: one workflow pins 1.4.2, another 1.4.3; a local 1.4.3 used to fail "outside CI 1.4.2" and now passes', () => {
|
|
const dir = project({
|
|
'.github/workflows/test.yml': workflow(lane('unit', '1.4.3')),
|
|
'.github/workflows/evals.yml': workflow(lane('evals', '1.4.2')),
|
|
});
|
|
for (const bun of ['1.4.2', '1.4.3']) expect(evaluatePins(collectPins(dir), { bun }, 'linux').verdict).toBe('pass');
|
|
expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').failures).toEqual([expect.stringContaining('any one of ')]);
|
|
});
|
|
|
|
test('non-CI exact pins stay required beside CI alternatives; another platform\'s CI pin is not an alternative here', () => {
|
|
const dir = project({
|
|
'.bun-version': '1.4.2\n',
|
|
'.github/workflows/ci.yml': workflow(lane('linux', '1.4.3'), lane('mac', '1.4.4', 'macos-latest')),
|
|
});
|
|
const r = evaluatePins(collectPins(dir), { bun: '1.4.3' }, 'linux');
|
|
expect(r.failures).toEqual(['bun 1.4.3 outside .bun-version 1.4.2 (.bun-version:1)']);
|
|
const lines = renderPinLines(r, 'linux');
|
|
expect(lines).toContain('PIN_EXACT_REQUIRED: bun 1.4.2 .bun-version:1');
|
|
expect(lines).toContain('PIN_CI_ALTERNATIVES: bun 1.4.3');
|
|
expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').verdict).toBe('fail');
|
|
});
|
|
|
|
test('Node pins render the same plan lines as Bun: .nvmrc required, CI node-version pins in different jobs alternatives', () => {
|
|
const dir = project({
|
|
'.nvmrc': '24.18.0\n',
|
|
'.github/workflows/ci.yml': 'jobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - with:\n node-version: 24.18.0\n b:\n runs-on: ubuntu-latest\n steps:\n - with:\n node-version: 22.20.0\n',
|
|
});
|
|
const lines = renderPinLines(evaluatePins(collectPins(dir), { node: '24.18.0' }, 'linux'), 'linux');
|
|
expect(lines).toContain('PIN_EXACT_REQUIRED: node 24.18.0 .nvmrc:1');
|
|
expect(lines).toContain('PIN_CI_ALTERNATIVES: node 24.18.0 22.20.0');
|
|
expect(lines.at(-1)).toBe('PINS_VERDICT: pass');
|
|
expect(evaluatePins(collectPins(dir), { node: '24.21.0' }, 'linux').failures).toEqual([expect.stringMatching(/^node 24\.21\.0 outside \.nvmrc 24\.18\.0/)]);
|
|
});
|
|
});
|
|
|
|
describe('bin/gstack-runtime-pins.ts', () => {
|
|
const run = (args: string[]) => spawnSync(process.execPath, [path.join(ROOT, 'bin/gstack-runtime-pins.ts'), ...args], { encoding: 'utf8', timeout: 30_000 });
|
|
|
|
test('prints PINS_VERDICT last, exits 1 on fail and 0 on pass, --json carries the same report', () => {
|
|
const dir = project({ 'package.json': '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n' });
|
|
const fail = run(['--project', dir, '--bun', '1.3.14', '--platform', 'linux']);
|
|
expect(fail.status).toBe(1);
|
|
expect(fail.stdout.trim().split('\n').at(-1)).toBe('PINS_VERDICT: fail');
|
|
expect(fail.stdout).toContain('PIN_FAIL: bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3)');
|
|
const pass = run(['--project', dir, '--bun', '1.4.2', '--json']);
|
|
expect(pass.status).toBe(0);
|
|
const json = JSON.parse(pass.stdout);
|
|
expect(json.schema_version).toBe(1);
|
|
expect(json.verdict).toBe('pass');
|
|
expect(json.pins).toHaveLength(1);
|
|
});
|
|
|
|
test('usage errors exit 2', () => {
|
|
expect(run([]).status).toBe(2);
|
|
expect(run(['--project', path.join(os.tmpdir(), 'no-such-dir-for-pins')]).status).toBe(2);
|
|
});
|
|
});
|