diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 0ec777cd9..77c86a617 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -125,23 +125,38 @@ if [ -f "$VERDICT" ]; then REC_RULE=$(gstack_install_verdict_field "$VERDICT" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$VERDICT" bun_install) fi LAUNCH_DIR="" RES_BIN="" RES_VER="" RES_RULE="" -PINS_OUT="" PINS_MODE=none RUNNER="" PIN="" PIN_SRC="" PIN_EXACT="" +PINS_OUT="" PINS_MODE=none RUNNER="" PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS="" # read_pins RUNNER — the project's pins through the doctor's parser # (lib/runtime-pins.ts: engines, packageManager, .tool-versions, .bun-version, # CI lanes) when RUNNER can run it, else the package.json / .bun-version subset -# read with jq. Sets PINS_MODE (parser|jq|none) and PIN_EXACT (the one exact Bun pin). +# read with jq. Sets PINS_MODE (parser|jq|none) and PIN_EXACT, the exact Bun +# to prefer: the one non-CI exact pin (two different ones conflict), else +# among this platform's CI exact pins (alternatives; any one satisfies CI) +# the install version when listed, else the highest verified one, else the +# highest one. read_pins() { - RUNNER="$1" PINS_OUT="" PINS_MODE=none PIN="" PIN_SRC="" PIN_EXACT="" + RUNNER="$1" PINS_OUT="" PINS_MODE=none PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS="" [ -n "$PROJECT" ] || return 0 [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null) if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then PINS_MODE=parser - _exact=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN: bun \([^ ]*\) exact \([^ ]*\) lane=[a-z]*$/\1 \2/p') - [ -n "$_exact" ] || return 0 - if [ "$(printf '%s\n' "$_exact" | cut -d' ' -f1 | sort -u | wc -l | tr -d ' ')" -gt 1 ]; then + _exact=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: bun \([^ ]*\) \([^ ]*\)$/\1 \2/p') + if [ "$(printf '%s\n' "$_exact" | grep -c .)" -gt 1 ]; then stop RUNTIME_PIN_CONFLICT "Bun conflict: $PROJECT pins more than one exact Bun ($(printf '%s\n' "$_exact" | sed 's/^\([^ ]*\) \(.*\)$/\2 pins \1/' | tr '\n' ';' | sed 's/;$//; s/;/; /g'))" "make the project's exact Bun pins agree" fi - PIN_EXACT=$(printf '%s\n' "$_exact" | head -1 | cut -d' ' -f1) PIN_SRC="$PROJECT/$(printf '%s\n' "$_exact" | head -1 | cut -d' ' -f2 | sed 's/:[0-9]*$//')" + if [ -n "$_exact" ]; then + PIN_EXACT=${_exact%% *} PIN_SRC="$PROJECT/$(printf '%s' "${_exact#* }" | sed 's/:[0-9]*$//')" + return 0 + fi + _alts=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_CI_ALTERNATIVES: bun //p') + [ -n "$_alts" ] || return 0 + case "$_alts" in *' '*) PIN_ALTS="$_alts" ;; esac + PIN_SRC="$PROJECT/.github/workflows (CI exact pins: $_alts)" + case " $_alts " in *" $INSTALL_VERSION "*) PIN_EXACT="$INSTALL_VERSION"; return 0 ;; esac + for _v in $(printf '%s\n' $_alts | sort -t. -k1,1nr -k2,2nr -k3,3nr); do + gstack_bun_archive_sha256 linux-x64 "$_v" >/dev/null && [ "$(gstack_bun_status "$_v")" = ok ] && { PIN_EXACT="$_v"; return 0; } + done + PIN_EXACT=$(printf '%s\n' $_alts | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -1) return 0 fi command -v jq >/dev/null 2>&1 || return 0 @@ -244,7 +259,7 @@ else resolve || conflict "bun $WANT in $STORE does not satisfy $PROJECT's pins $(pins_text) (judged: ${_gb_judged:-none})" fi [ -n "${GSTACK_BUN_INSTALL_VERSION:-}" ] && say "install version $INSTALL_VERSION from GSTACK_BUN_INSTALL_VERSION (default $GSTACK_BUN_DEFAULT)" - say "bun $RES_VER at $RES_BIN ($(gstack_bun_rule_text "$RES_RULE"); satisfies gstack's supported minimum $GSTACK_BUN_TESTED${PIN_EXACT:+ and the project pin $PIN_EXACT}$([ -z "$PIN_EXACT" ] && [ -n "$PIN" ] && printf ' and the project pin %s' "$PIN")$([ -z "$PIN_EXACT" ] && [ "$PINS_MODE" = parser ] && printf ' and the project pins'))" + say "bun $RES_VER at $RES_BIN ($(gstack_bun_rule_text "$RES_RULE"); satisfies gstack's supported minimum $GSTACK_BUN_TESTED$([ -n "$PIN_ALTS" ] && printf " and the project's CI pins (alternatives: %s)" "$PIN_ALTS" || printf '%s' "${PIN_EXACT:+ and the project pin $PIN_EXACT}")$([ -z "$PIN_EXACT" ] && [ -n "$PIN" ] && printf ' and the project pin %s' "$PIN")$([ -z "$PIN_EXACT" ] && [ "$PINS_MODE" = parser ] && printf ' and the project pins'))" fi if [ -n "$RES_BIN" ] && [ "$(gstack_realpath "$(command -v bun 2>/dev/null || echo /nonexistent)" 2>/dev/null)" != "$RES_BIN" ]; then LAUNCH_DIR=$(dirname "$RES_BIN") diff --git a/lib/pregate/runtime.ts b/lib/pregate/runtime.ts index 0ef0c6b57..9d692809b 100644 --- a/lib/pregate/runtime.ts +++ b/lib/pregate/runtime.ts @@ -21,7 +21,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { findInstallVerdict, readInstallVerdict, sourceRoot } from '../install-verdict'; import { resultCodeSuffix } from '../result-codes'; -import { collectPins, evaluatePins, parseVersion, satisfies } from '../runtime-pins'; +import { collectPins, evaluatePins, exactPlan, parseVersion, satisfies } from '../runtime-pins'; import { resolveStateRoot } from '../state-root'; type Env = NodeJS.ProcessEnv; @@ -106,16 +106,24 @@ function enginesBun(file: string): string | null { try { const v = JSON.parse(fs.readFileSync(file, 'utf8'))?.engines?.bun; return typeof v === 'string' && v.trim() ? v.trim() : null; } catch { return null; } } -const PLATFORM_LANE: Record = { linux: 'linux', darwin: 'macos', win32: 'windows' }; +const byVersionDesc = (a: string, b: string) => { const x = parseVersion(a)!, y = parseVersion(b)!; return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]; }; -/** The project's pins as the resolver's eligibility hook, and its one applicable exact Bun pin. */ -export function projectBunPins(repoRoot: string | null): { pinExact: string | null; accept: (version: string) => boolean } { +/** + * The project's pins as the resolver's eligibility hook, and the exact Bun to + * prefer (read_pins in bin/gstack-capy-install): the one non-CI exact pin, + * else among the CI alternatives the install version, the highest verified, + * or the highest. + */ +export function projectBunPins(repoRoot: string | null, env: Env = process.env): { pinExact: string | null; accept: (version: string) => boolean } { const gstackPin = enginesBun(path.join(GSTACK_ROOT, 'package.json')); const pins = repoRoot ? collectPins(repoRoot).filter(p => p.tool === 'bun') : []; - const here = PLATFORM_LANE[process.platform] ?? 'any'; - const exact = [...new Set(pins.filter(p => p.kind === 'exact' && (p.lane === 'any' || p.lane === here)).map(p => p.constraint.replace(/^v/, '')))]; + const plan = exactPlan(pins, 'bun'); + const alts = plan.ciAlternatives.filter(v => parseVersion(v)).sort(byVersionDesc); + const installVersion = bunInstallVersion(env); + const pinExact = plan.required.length ? (plan.required.length === 1 && parseVersion(plan.required[0].version) ? plan.required[0].version : null) + : alts.find(v => v === installVersion) ?? alts.find(v => BUN_VERIFIED.includes(v) && bunStatusOk(v)) ?? alts[0] ?? null; return { - pinExact: exact.length === 1 && parseVersion(exact[0]) ? exact[0] : null, + pinExact, accept: (version: string) => (!gstackPin || satisfies(version, gstackPin) === true) && evaluatePins(pins, { bun: version }).failures.length === 0, }; } @@ -157,7 +165,7 @@ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchR } else { verdict = 'ok'; } - const { pinExact, accept } = projectBunPins(project); + const { pinExact, accept } = projectBunPins(project, env); const { resolution } = resolveBun({ store: bunStore(resolveStateRoot(env)), env, pinExact, accept }); if (resolution) return launch(resolution.bin, resolution.version, `resolved: ${resolution.rule}`, verdict, verdictLine); return { dir: null, version: null, env, label: `PATH (no eligible Bun in the runtime store, $BUN_INSTALL/bin, ~/.bun/bin or PATH)${verdictLine ? `; ${verdictLine}` : ''}`, verdict, verdictLine }; diff --git a/lib/runtime-pins.ts b/lib/runtime-pins.ts index 9eae9c67a..0c9736dad 100644 --- a/lib/runtime-pins.ts +++ b/lib/runtime-pins.ts @@ -11,6 +11,11 @@ * 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'; @@ -210,20 +215,43 @@ export function evaluatePins(pins: Pin[], running: Running, platform: NodeJS.Pla 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 (satisfies(version, pin.constraint) === false) unmet.push(pin); + 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}`); - failures.push(`${tool} ${version} outside ${first.pin.field} ${first.pin.constraint} (${first.pin.source}${context.length ? '; ' + context.join('; ') : ''})`); + 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'; @@ -231,6 +259,9 @@ export function renderPinLines(report: PinReport, platform: NodeJS.Platform = pr 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}`); + const plan = exactPlan(report.pins, 'bun', platform); + for (const r of plan.required) lines.push(`PIN_EXACT_REQUIRED: bun ${r.version} ${r.source}`); + if (plan.ciAlternatives.length) lines.push(`PIN_CI_ALTERNATIVES: bun ${plan.ciAlternatives.join(' ')}`); lines.push(`PINS_VERDICT: ${report.verdict}`); return lines; } diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 759822c6c..c2f0ca33d 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -437,6 +437,41 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti expect(outside.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `); }); + // Integration item 1: gstack's own CI pins 1.4.2 on every lane and 1.4.3 on + // the install-default job. They are alternatives, so the real checkout as + // --project certifies with either Bun; a repo whose CI pins only 1.4.3 still + // refuses 1.4.2. + test('the real gstack checkout as --project with a separate global install certifies with ~/.bun 1.4.2 and with 1.4.3; nothing is downloaded', () => { + for (const version of ['1.4.2', '1.4.3']) { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + realBun(path.join(f.home, '.bun/bin/bun'), version); + const r = run(f, ['--for', 'review', '--project', REPO], {}, f.home); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stderr).not.toContain('RUNTIME_PIN_CONFLICT'); + expect(r.stdout).toContain(`bun ${version} at ${f.home}/.bun/bin/bun ($BUN_INSTALL/bin; satisfies gstack's supported minimum 1.4.2 and the project's CI pins (alternatives: 1.4.2 1.4.3))`); + expect(f.calls().some(l => l.startsWith('curl '))).toBe(false); + expect(verdictFor(f, r)).toMatchObject({ project: REPO, bun_version: version, bun_rule: 'BUN_INSTALL' }); + expect(run(f, ['--check', '--for', 'review', '--project', REPO], {}, f.home).status).toBe(0); + } + }); + + test('CI alternatives with nothing installed: the install default is published when it is one of them; a gbrain-like repo (CI 1.4.3 only) refuses ~/.bun 1.4.2 and installs 1.4.3', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.home); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (`); + const g = fixture({ bun: '1.3.14' }); + withPins(g); + realBun(path.join(g.home, '.bun/bin/bun'), '1.4.2'); + const project = ciProject(g, 'gbrain', '1.4.3'); + const gr = run(g, ['--for', 'review'], { STUB_CURL_ZIP: zipFor(g, '1.4.3') }, project); + expect(gr.status, gr.stdout + gr.stderr).toBe(0); + expect(gr.stdout).toContain(`judged: bun 1.4.2 at ${g.home}/.bun/bin/bun (BUN_INSTALL)`); + expect(gr.stdout).toContain(`bun 1.4.3 at ${store(g, '1.4.3')}/bun (the project's exact pin`); + }); + test('no installed Bun satisfies the pins: the line names the Bun actually judged, the exact pin is published in the store, and the next --check agrees', () => { const f = fixture({ bun: '1.3.14' }); withPins(f); diff --git a/test/runtime-pins.test.ts b/test/runtime-pins.test.ts index 021f8fc10..6374f3421 100644 --- a/test/runtime-pins.test.ts +++ b/test/runtime-pins.test.ts @@ -136,6 +136,54 @@ describe('collectPins and evaluatePins', () => { }); }); +// 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 1.4.3 install-default job) passes on 1.4.2 and on 1.4.3, 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', '1.4.3']); + for (const bun of ['1.4.2', '1.4.3']) 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, 1.4.3 satisfies CI'); + expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain('PIN_CI_ALTERNATIVES: bun 1.4.2 1.4.3'); + }); + + 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'); + }); +}); + 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 });