Files
gstack/lib/runtime-pins.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

270 lines
13 KiB
TypeScript

/**
* runtime-pins — what a target project pins for Bun and Node, and whether the
* running versions satisfy it. Feeds the doctor's `project pins` row
* (bin/gstack-runtime-pins.ts; bin/gstack-doctor-check.sh reads the
* PINS_VERDICT line) and nothing else reads project pins by hand.
*
* Sources, each kept with its file:line: package.json (`packageManager`,
* `engines.bun`, `engines.node`), `.tool-versions`, `.nvmrc`, `.bun-version`
* and `bun-version:` / `node-version:` lines in `.github/workflows/*.yml`. A
* range (`>=1.4.2`) is told apart from an exact pin (`1.4.2`); a CI pin keeps
* the platform lane of the job's `runs-on`, so a Windows job's pin never fails
* a Linux run. A value this module cannot evaluate (`lts/*`, a matrix
* expression) is reported as skipped, never silently dropped.
*
* CI exact pins that apply to this platform are alternatives: a repository's
* lanes may test several Bun versions (gstack pins its minimum everywhere and
* runs one job on its install default), so the running Bun satisfies CI when
* it equals any one of them. CI ranges and every non-CI pin stay required.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export type Tool = 'bun' | 'node';
export type Lane = 'any' | 'linux' | 'macos' | 'windows';
export interface Pin {
tool: Tool;
/** The constraint as written (`>=1.4.2`, `1.4.2`, `24`, `lts/*`). */
constraint: string;
kind: 'exact' | 'range' | 'unevaluable';
/** `package.json:81`, `.github/workflows/x.yml:84`. */
source: string;
/** Where the pin comes from: `engines.bun`, `packageManager`, `.nvmrc`, `CI`. */
field: string;
lane: Lane;
}
export interface Running { bun?: string; node?: string }
export interface PinReport {
pins: Pin[];
/** Distinct effective constraints with their first source and count, for a one-line row. */
summary: string[];
/** Human lines: one per failing tool. */
failures: string[];
/** Pins skipped as unevaluable, one line each. */
skipped: string[];
verdict: 'pass' | 'fail' | 'none';
}
const RANGE_OP = /^(>=|<=|>|<|=|\^|~)/;
type Ver = [number, number, number];
/** Parse `v1.2.3`, `1.2`, `24` into a triple; null when not a version. */
export function parseVersion(text: string): Ver | null {
const m = text.trim().replace(/^v/, '').match(/^(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
if (!m) return null;
const n = (s: string | undefined) => (s === undefined || s === 'x' || s === '*' ? 0 : Number(s));
return [Number(m[1]), n(m[2]), n(m[3])];
}
const cmp = (a: Ver, b: Ver) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
/** Components written (`1.4` → 2, `24` → 1, `1.4.x` → 2) for prefix pins. */
function writtenParts(text: string): number {
const parts = text.trim().replace(/^v/, '').split(/[-+]/)[0].split('.');
return parts.filter(p => p !== 'x' && p !== '*').length;
}
function satisfiesOne(version: Ver, clause: string): boolean | null {
const op = clause.match(RANGE_OP)?.[1] ?? '';
const rest = clause.slice(op.length).trim();
if (rest === '' || rest === '*' || rest === 'x') return true;
const target = parseVersion(rest);
if (!target) return null;
const parts = writtenParts(rest);
const upper: Ver = parts === 1 ? [target[0] + 1, 0, 0] : parts === 2 ? [target[0], target[1] + 1, 0] : [target[0], target[1], target[2] + 1];
switch (op) {
case '>=': return cmp(version, target) >= 0;
case '>': return parts === 3 ? cmp(version, target) > 0 : cmp(version, upper) >= 0;
case '<=': return parts === 3 ? cmp(version, target) <= 0 : cmp(version, upper) < 0;
case '<': return cmp(version, target) < 0;
case '^': {
const top: Ver = target[0] > 0 ? [target[0] + 1, 0, 0] : target[1] > 0 ? [0, target[1] + 1, 0] : [0, 0, target[2] + 1];
return cmp(version, target) >= 0 && cmp(version, top) < 0;
}
case '~': return cmp(version, target) >= 0 && cmp(version, parts === 1 ? [target[0] + 1, 0, 0] : [target[0], target[1] + 1, 0]) < 0;
default: return cmp(version, target) >= 0 && cmp(version, upper) < 0;
}
}
/**
* `satisfies('1.3.14', '>=1.4.2')` → false. Supports `||` alternatives,
* space-joined conjunctions, `>= > <= < = ^ ~`, `x`/`*` wildcards and partial
* versions (`24` means 24.x.x). Returns null when the constraint cannot be
* evaluated (`lts/*`, `node`, a `${{ }}` expression).
*/
export function satisfies(version: string, constraint: string): boolean | null {
const v = parseVersion(version);
if (!v) return null;
let sawNull = false;
for (const alt of constraint.split('||')) {
const clauses = alt.trim().replace(/\s*(>=|<=|>|<|=|\^|~)\s*/g, ' $1').trim().split(/\s+/).filter(Boolean);
if (clauses.length === 0) continue;
const results = clauses.map(c => satisfiesOne(v, c));
if (results.some(r => r === null)) { sawNull = true; continue; }
if (results.every(Boolean)) return true;
}
return sawNull ? null : false;
}
export function classify(constraint: string): Pin['kind'] {
const trimmed = constraint.trim();
if (satisfies('0.0.0', trimmed) === null) return 'unevaluable';
if (RANGE_OP.test(trimmed) || /\|\||\s|[x*]/.test(trimmed)) return 'range';
return writtenParts(trimmed) === 3 ? 'exact' : 'range';
}
export function laneOf(runsOn: string): Lane {
const value = runsOn.toLowerCase();
if (value.includes('windows')) return 'windows';
if (value.includes('macos')) return 'macos';
if (value.includes('ubuntu') || value.includes('linux')) return 'linux';
return 'any';
}
function lineOf(text: string, needle: RegExp): number {
const lines = text.split('\n');
const i = lines.findIndex(l => needle.test(l));
return i < 0 ? 1 : i + 1;
}
function readIf(file: string): string | null {
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
}
/** Every pin the project declares, in source order. */
export function collectPins(projectDir: string): Pin[] {
const pins: Pin[] = [];
const pkgText = readIf(path.join(projectDir, 'package.json'));
if (pkgText) {
let pkg: { packageManager?: unknown; engines?: Record<string, unknown> } = {};
try { pkg = JSON.parse(pkgText); } catch { /* reported as no pins from package.json */ }
if (typeof pkg.packageManager === 'string') {
const m = pkg.packageManager.match(/^(bun|node)@([^+]+)/);
if (m) pins.push({ tool: m[1] as Tool, constraint: m[2], kind: classify(m[2]), source: `package.json:${lineOf(pkgText, /"packageManager"/)}`, field: 'packageManager', lane: 'any' });
}
for (const tool of ['bun', 'node'] as const) {
const value = pkg.engines?.[tool];
if (typeof value !== 'string') continue;
pins.push({ tool, constraint: value, kind: classify(value), source: `package.json:${lineOf(pkgText, new RegExp(`"${tool}"\\s*:`))}`, field: `engines.${tool}`, lane: 'any' });
}
}
const toolVersions = readIf(path.join(projectDir, '.tool-versions'));
if (toolVersions) {
toolVersions.split('\n').forEach((line, i) => {
const m = line.trim().match(/^(bun|nodejs|node)\s+(\S+)/);
if (!m) return;
const tool: Tool = m[1] === 'bun' ? 'bun' : 'node';
pins.push({ tool, constraint: m[2], kind: classify(m[2]), source: `.tool-versions:${i + 1}`, field: '.tool-versions', lane: 'any' });
});
}
for (const [file, tool] of [['.nvmrc', 'node'], ['.bun-version', 'bun']] as const) {
const text = readIf(path.join(projectDir, file));
const value = text?.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
if (value) pins.push({ tool, constraint: value, kind: classify(value), source: `${file}:1`, field: file, lane: 'any' });
}
const workflows = path.join(projectDir, '.github', 'workflows');
let names: string[] = [];
try { names = fs.readdirSync(workflows).filter(n => /\.ya?ml$/.test(n)).sort(); } catch { /* no workflows */ }
for (const name of names) {
const text = readIf(path.join(workflows, name));
if (!text) continue;
let lane: Lane = 'any';
text.split('\n').forEach((line, i) => {
const runsOn = line.match(/^\s*runs-on:\s*(.+?)\s*$/);
if (runsOn) { lane = laneOf(runsOn[1]); return; }
const m = line.match(/^\s*(bun|node)-version:\s*["']?(\$\{\{.*?\}\}|[^"'#\s]+)["']?\s*(?:#.*)?$/);
if (!m) return;
const constraint = m[2];
const kind = constraint.includes('${{') ? 'unevaluable' : classify(constraint);
pins.push({ tool: m[1] as Tool, constraint, kind, source: `.github/workflows/${name}:${i + 1}`, field: 'CI', lane });
});
}
return pins;
}
const PLATFORM_LANE: Record<string, Lane> = { linux: 'linux', darwin: 'macos', win32: 'windows' };
/** Distinct (tool, field, constraint, lane) groups with their first source and count. */
function groupPins(pins: Pin[]): Array<{ pin: Pin; count: number }> {
const groups = new Map<string, { pin: Pin; count: number }>();
for (const pin of pins) {
const key = `${pin.tool}\t${pin.field === 'CI' ? 'CI' : pin.field}\t${pin.constraint}\t${pin.lane}`;
const g = groups.get(key);
if (g) g.count++; else groups.set(key, { pin, count: 1 });
}
return [...groups.values()];
}
const describe = (g: { pin: Pin; count: number }) =>
`${g.pin.field === 'CI' ? `CI${g.pin.lane === 'any' ? '' : ` ${g.pin.lane} lane`}` : g.pin.field}, ${g.pin.source.replace(/^\.github\/workflows\//, '')}${g.count > 1 ? ` +${g.count - 1} more` : ''}`;
/** Evaluate the running versions against the pins that apply on this platform. */
export function evaluatePins(pins: Pin[], running: Running, platform: NodeJS.Platform = process.platform): PinReport {
const here = PLATFORM_LANE[platform] ?? 'any';
const failures: string[] = [];
const skipped: string[] = [];
const summary = groupPins(pins).map(g => `${g.pin.tool} ${g.pin.constraint} (${describe(g)})`);
let evaluated = 0;
for (const tool of ['bun', 'node'] as const) {
const mine = pins.filter(p => p.tool === tool);
if (mine.length === 0) continue;
const version = running[tool];
const applicable = mine.filter(p => p.lane === 'any' || p.lane === here);
const unmet: Pin[] = [];
const ciExact: Pin[] = [];
for (const pin of applicable) {
if (pin.kind === 'unevaluable') { skipped.push(`${tool} ${pin.constraint} (${pin.source}) not evaluated`); continue; }
if (!version) { skipped.push(`${tool} ${pin.constraint} (${pin.source}): ${tool} not found on PATH`); continue; }
evaluated++;
if (pin.field === 'CI' && pin.kind === 'exact') ciExact.push(pin);
else if (satisfies(version, pin.constraint) === false) unmet.push(pin);
}
const ciMet = ciExact.some(pin => satisfies(version!, pin.constraint) !== false);
if (!ciMet) unmet.push(...ciExact);
if (unmet.length === 0) continue;
const [first, ...rest] = groupPins(unmet);
const context = rest.map(g => `${describe(g)} pins ${g.pin.constraint}`);
const alternatives = ciMet ? [] : [...new Set(ciExact.map(p => p.constraint))];
const anyOf = alternatives.length > 1 ? `; CI exact pins are alternatives, any one of ${alternatives.join(', ')} satisfies CI` : '';
failures.push(`${tool} ${version} outside ${first.pin.field} ${first.pin.constraint} (${first.pin.source}${context.length ? '; ' + context.join('; ') : ''}${anyOf})`);
}
return { pins, summary, failures, skipped, verdict: failures.length ? 'fail' : evaluated ? 'pass' : 'none' };
}
export interface ExactPlan {
/** Distinct non-CI exact pins (engines, packageManager, .tool-versions, .bun-version): all required. */
required: Array<{ version: string; source: string }>;
/** Distinct CI exact pins for this platform's lanes: any one satisfies CI. */
ciAlternatives: string[];
}
/** The exact pins of one tool that apply on this platform, split into required and CI alternatives. */
export function exactPlan(pins: Pin[], tool: Tool, platform: NodeJS.Platform = process.platform): ExactPlan {
const here = PLATFORM_LANE[platform] ?? 'any';
const exact = pins.filter(p => p.tool === tool && p.kind === 'exact' && (p.lane === 'any' || p.lane === here));
const strip = (v: string) => v.replace(/^v/, '');
const required: ExactPlan['required'] = [];
for (const p of exact.filter(p => p.field !== 'CI')) if (!required.some(r => r.version === strip(p.constraint))) required.push({ version: strip(p.constraint), source: p.source });
return { required, ciAlternatives: [...new Set(exact.filter(p => p.field === 'CI').map(p => strip(p.constraint)))] };
}
/** The wire lines bin/gstack-doctor-check.sh reads (PINS_VERDICT last). */
export function renderPinLines(report: PinReport, platform: NodeJS.Platform = process.platform): string[] {
const here = PLATFORM_LANE[platform] ?? 'any';
const lines = report.pins.map(p => `PIN: ${p.tool} ${p.constraint} ${p.kind} ${p.source} lane=${p.lane}${p.lane !== 'any' && p.lane !== here ? ' (other platform)' : ''}`);
for (const s of report.summary) lines.push(`PIN_SUMMARY: ${s}`);
for (const s of report.skipped) lines.push(`PIN_SKIP: ${s}`);
for (const f of report.failures) lines.push(`PIN_FAIL: ${f}`);
for (const tool of ['bun', 'node'] as const) {
const plan = exactPlan(report.pins, tool, platform);
for (const r of plan.required) lines.push(`PIN_EXACT_REQUIRED: ${tool} ${r.version} ${r.source}`);
if (plan.ciAlternatives.length) lines.push(`PIN_CI_ALTERNATIVES: ${tool} ${plan.ciAlternatives.join(' ')}`);
}
lines.push(`PINS_VERDICT: ${report.verdict}`);
return lines;
}