diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index aff850978..65d14b08f 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -238,12 +238,43 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + # Push runs check out main with `checkout -B main`, so the clone holds a + # local main; PR runs are a detached merge ref and train dispatches sit on + # train/. A fixture that made its own main passed its PR and then + # failed every push to main (#3237, run 38999168852). Give every run the + # push layout so a PR run catches what the main push would. + - name: Match main's local branch layout on PR and dispatch runs + if: github.ref != 'refs/heads/main' + run: git checkout -q -B main - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile + - name: Read the Playwright version for the browser cache key + id: playwright + run: echo "version=$(node -p 'require("playwright-core/package.json").version')" >> "$GITHUB_OUTPUT" + # Keyed on the Playwright version, which fixes the Chromium revision: a + # lockfile bump that keeps Playwright reuses the browser, and a Playwright + # bump never restores a stale revision. + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: linux-playwright-chromium-${{ steps.playwright.outputs.version }} + # The cache restores the browser; --with-deps still runs apt for system + # libraries. On 2026-10-21 apt stalled on an archive.ubuntu.com fetch and + # held free-suite (12) for the whole 20-minute job timeout (run + # 39641712129). Each attempt gets 5 minutes (a warm install takes about + # 15 s) and the install alone is retried once. This retries infrastructure, + # never a test. - name: Install Playwright Chromium + id: playwright_install + timeout-minutes: 5 + continue-on-error: true + run: npx playwright install --with-deps chromium + - name: Retry the Playwright Chromium install once + if: steps.playwright_install.outcome == 'failure' + timeout-minutes: 5 run: npx playwright install --with-deps chromium - name: Configure the bundled Chromium sandbox helper run: | @@ -294,6 +325,15 @@ jobs: with: persist-credentials: false + # Push runs check out main with `checkout -B main`, so the clone holds a + # local main; PR runs are a detached merge ref and train dispatches sit on + # train/. A fixture that made its own main passed its PR and then + # failed every push to main (#3237, run 38999168852). Give every run the + # push layout so a PR run catches what the main push would. + - name: Match main's local branch layout on PR and dispatch runs + if: github.ref != 'refs/heads/main' + run: git checkout -q -B main + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 @@ -314,16 +354,33 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Read the Playwright version for the browser cache key + id: playwright + run: echo "version=$(node -p 'require("playwright-core/package.json").version')" >> "$GITHUB_OUTPUT" + + # Keyed on the Playwright version, which fixes the Chromium revision: a + # lockfile bump that keeps Playwright reuses the browser, and a Playwright + # bump never restores a stale revision. - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright - key: linux-playwright-${{ hashFiles('bun.lock') }} - restore-keys: | - linux-playwright- + key: linux-playwright-chromium-${{ steps.playwright.outputs.version }} - # Cache restores browser binaries; install is still required for system - # deps and is a fast no-op for already-present browsers. + # The cache restores the browser; --with-deps still runs apt for system + # libraries. On 2026-10-21 apt stalled on an archive.ubuntu.com fetch and + # held free-suite (12) for the whole 20-minute job timeout (run + # 39641712129). Each attempt gets 5 minutes (a warm install takes about + # 15 s) and the install alone is retried once. This retries infrastructure, + # never a test. - name: Install Playwright Chromium + id: playwright_install + timeout-minutes: 5 + continue-on-error: true + run: npx playwright install --with-deps chromium + + - name: Retry the Playwright Chromium install once + if: steps.playwright_install.outcome == 'failure' + timeout-minutes: 5 run: npx playwright install --with-deps chromium - name: Configure the bundled Chromium sandbox helper diff --git a/CHANGELOG.md b/CHANGELOG.md index 9652e71fd..900315729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [Unreleased] + +**A stalled apt mirror can no longer hold a Free Tests shard for 20 minutes, PR runs now see main's branch layout, and gstack's install default moves to Bun 1.4.4.** On 2026-10-21, free-suite (12) in run 39641712129 sat in "Install Playwright Chromium" until the job timeout cancelled it. The browser had already come from cache: `--with-deps` was waiting on apt's fetch of noble-security from archive.ubuntu.com. No tests ran, and a whole merge train had to be rebuilt. Separately, a push to main checks out a local `main` while PR runs check out a detached merge ref, so a fixture that created its own `main` passed its PR and then failed every main push until #3237 (run 38999168852). And Capy images and gbrain now run Bun 1.4.4, which no CI lane tested, while the installer still downloaded 1.4.3. + +### Fixed +- **`.github/workflows/free-tests.yml`**: in free-suite and quick-bun-default, the Playwright install gets its own 5-minute timeout (a warm install takes about 15 s), and that install step alone gets one bounded retry, also 5 minutes. This retries infrastructure, never a test: a second failure fails the job. The browser cache is keyed on the Playwright version instead of `bun.lock`, with no prefix fallback, so a lockfile bump keeps the browser and a Playwright bump never restores a stale revision. quick-bun-default, which had no browser cache, now has one. +- **`.github/workflows/free-tests.yml`**: free-suite and quick-bun-default run `git checkout -q -B main` right after checkout on every run whose ref is not main (PRs and train dispatches). Their clone then has the same local `main` a push run gets, so a PR run catches what the main push would. +- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_DEFAULT` is 1.4.4, the Bun that Capy images ship, that gbrain pins, and that carries the CVE-2026-111497 fix. quick-bun-default runs the quick tier on it. The 1.4.2 minimum, engines.bun, Dockerfile.ci, every other CI lane and the verified archives (1.4.2, 1.4.3, 1.4.4) are unchanged. `docs/capy-install.md` and `docs/troubleshooting.md` name the new default. + +### Tests +- `test/free-tests-workflow-wiring.test.ts` requires the version-keyed cache, the 5-minute install, its single retry as the only `continue-on-error` step in each job, and the local-main step. Both new cases fail against the previous workflow. +- `test/gstack-capy-install.test.ts` reads the install default from `GSTACK_BUN_DEFAULT` wherever it meant "the install default", so the next bump doesn't need a literal sweep. `test/bun-resolve.test.ts` moves its gbrain-like project pin from 1.4.3 to 1.4.4, gbrain's real pin. + +### Gate integrity +- One gate edit to review: the Playwright install step in the required Free Tests lane is `continue-on-error`, and a retry step that is not marked that way repeats it. Verdict: not weakening. A shard still fails unless one of the two installs succeeds, and every test, threshold, timeout and trial count stays the same. The local-main step strengthens PR runs. No test assertion was removed. + ## [1.91.186.0] - 2026-10-24 **Approving an autoplan run no longer fails one time in ten because its run id looks like a card number.** Today's daily flaky-fix pass found `test/capy-captain-flow.test.ts` failing once and passing on the serial retry in 7 of the 57 Free Tests runs from 2026-10-21 to 2026-10-23, one of them a push to main (39878113535). Every failure had the same assertion: `gstack-approvals import` refused the approval row with "field ask: pii.cc". The default autoplan run id is `run-YYYYMMDDHHMMSS`, the import writes it into the row's ask and source, and the credit-card rule saw those 14 digits as a card number whenever they passed the Luhn check, which a timestamp does about one time in ten. A real user's approval import failed the same way. diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index 9dda4a073..f21b7ed65 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -18,7 +18,7 @@ # at or above the minimum, and one CI job runs the quick tier on it. GSTACK_BUN_FLOOR="1.3.3" GSTACK_BUN_TESTED="1.4.2" -GSTACK_BUN_DEFAULT="1.4.3" +GSTACK_BUN_DEFAULT="1.4.4" # gstack_bun_archive_sha256 TARGET [VERSION] — the SHA-256 of # https://github.com/oven-sh/bun/releases/download/bun-v$VERSION/bun-TARGET.zip diff --git a/docs/capy-install.md b/docs/capy-install.md index 404f85b2f..a52e9aa20 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -141,7 +141,7 @@ session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` s packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes for this platform). CI exact pins for this platform are alternatives: a repository may test several Bun versions (gstack's own CI pins 1.4.2 and - runs one job on 1.4.3), so a Bun equal to any one of them satisfies CI; + runs one job on 1.4.4), so a Bun equal to any one of them satisfies CI; CI ranges and every non-CI pin stay required. One resolver picks the Bun (`gstack_bun_resolve` in `bin/gstack-bun-version.sh`; `resolveBun` in `lib/pregate/runtime.ts` is its TypeScript twin), and the installer, `--check`, the doctor, the bun @@ -160,7 +160,7 @@ session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` s exact pin when gstack carries a verified archive for it (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3, 1.4.4); with only CI exact pins, the install default when it is one of them, else the highest verified one; else the - install default `GSTACK_BUN_DEFAULT` (1.4.3, above the 1.4.2 minimum; one + install default `GSTACK_BUN_DEFAULT` (1.4.4, above the 1.4.2 minimum; one CI job runs the quick free tier on it). `GSTACK_BUN_INSTALL_VERSION=` overrides the default; a version without a verified archive or below the minimum stops the install diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 027d071eb..28320036f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1590,7 +1590,7 @@ next `--check` passes. ### `gstack-capy-install: GSTACK_BUN_INSTALL_VERSION= is not a verified Bun at or above gstack's supported minimum ...; fix: ... (BUN_INSTALL_VERSION_INVALID)` **Meaning.** `GSTACK_BUN_INSTALL_VERSION` overrides the Bun the installer -downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.3). It +downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.4). It must be listed in `GSTACK_BUN_VERIFIED` (a SHA-256-verified archive) and sit at or above the supported minimum (`GSTACK_BUN_TESTED`). Nothing was installed. diff --git a/test/bun-resolve.test.ts b/test/bun-resolve.test.ts index 92c19327f..9af139cd9 100644 --- a/test/bun-resolve.test.ts +++ b/test/bun-resolve.test.ts @@ -3,7 +3,7 @@ * 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 + * $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. @@ -41,7 +41,7 @@ function layout(opts: { storeVersions?: string[]; homeVersion?: string } = {}) { 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'); + 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'); @@ -74,24 +74,24 @@ function tsResolve(l: ReturnType, project: string | null): string } 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) { + 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(store143); + 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.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'); + 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, `${home143} 1.4.3 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) { + 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); } @@ -106,20 +106,20 @@ describe('one Bun resolution contract, two twins', () => { }); 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 l = layout({ storeVersions: ['1.4.4'] }); 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.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.3/bun')} 1.4.3 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.3'] }); + 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); diff --git a/test/free-tests-workflow-wiring.test.ts b/test/free-tests-workflow-wiring.test.ts index f242e392c..cd8322971 100644 --- a/test/free-tests-workflow-wiring.test.ts +++ b/test/free-tests-workflow-wiring.test.ts @@ -131,6 +131,41 @@ describe('free-tests workflow wiring', () => { } finally { fs.rmSync(directory, { recursive: true, force: true }); } }); + test('every Linux Playwright install has a version-keyed browser cache, 5-minute attempts and one retry of the install alone (run 39641712129)', () => { + const jobs = (Bun.YAML.parse(source) as any).jobs; + const installing = Object.keys(jobs).filter(name => jobs[name].steps.some((step: any) => step.run?.includes('playwright install --with-deps'))); + expect(installing.sort()).toEqual(['free-suite', 'quick-bun-default']); + for (const name of installing) { + const steps = jobs[name].steps; + const version = steps.findIndex((step: any) => step.id === 'playwright'); + const cache = steps.findIndex((step: any) => step.with?.path === '~/.cache/ms-playwright'); + const install = steps.findIndex((step: any) => step.id === 'playwright_install'); + const retry = steps.findIndex((step: any) => step.if === "steps.playwright_install.outcome == 'failure'"); + expect(steps[version].run, name).toContain('require("playwright-core/package.json").version'); + expect(steps[cache].with.key, name).toBe('linux-playwright-chromium-${{ steps.playwright.outputs.version }}'); + expect(steps[cache].with['restore-keys'], name).toBeUndefined(); + expect(version, name).toBeLessThan(cache); + expect(cache, name).toBeLessThan(install); + expect(retry, name).toBe(install + 1); + for (const step of [steps[install], steps[retry]]) { + expect(step['timeout-minutes'], name).toBe(5); + expect(step.run, name).toBe('npx playwright install --with-deps chromium'); + } + expect(steps[install]['continue-on-error'], name).toBe(true); + expect(steps.filter((step: any) => step['continue-on-error']), name).toEqual([steps[install]]); + } + }); + + test('test jobs give PR and dispatch runs the local main that push runs get from checkout (#3237)', () => { + const jobs = (Bun.YAML.parse(source) as any).jobs; + for (const name of ['free-suite', 'quick-bun-default']) { + const [checkout, guard] = jobs[name].steps; + expect(checkout.uses, name).toStartWith('actions/checkout@'); + expect(guard.if, name).toBe("github.ref != 'refs/heads/main'"); + expect(guard.run, name).toBe('git checkout -q -B main'); + } + }); + test('least-privilege token: contents read-only, credentials not persisted', () => { // The job executes PR-controlled code (install lifecycle scripts + the // suite itself). A default-grant GITHUB_TOKEN persisted into .git/config diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index d851ec135..004873818 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -15,6 +15,7 @@ import * as os from 'os'; import * as path from 'path'; const REPO = path.resolve(import.meta.dir, '..'); +const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1]; const bases: string[] = []; afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); @@ -211,17 +212,17 @@ describe('gstack-capy-install', () => { const f = fixture({ bun: '1.3.14' }); const zip = path.join(f.base, 'bad.zip'); fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true }); - script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.3']); + script(path.join(f.base, 'z/bun-linux-x64/bun'), [`echo ${BUN_DEFAULT}`]); expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0); const r = run(f, [], { STUB_CURL_ZIP: zip }); expect(r.status).toBe(3); expect(r.stderr).toContain('SHA-256 mismatch'); expect(r.stderr).toContain('nothing installed'); expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1); - expect(f.calls()[0]).toContain('https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64'); + expect(f.calls()[0]).toContain(`https://github.com/oven-sh/bun/releases/download/bun-v${BUN_DEFAULT}/bun-linux-x64`); expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false); - expect(fs.existsSync(store(f, '1.4.3'))).toBe(false); - expect(fs.readdirSync(path.dirname(store(f, '1.4.3')))).toEqual([]); + expect(fs.existsSync(store(f, BUN_DEFAULT))).toBe(false); + expect(fs.readdirSync(path.dirname(store(f, BUN_DEFAULT)))).toEqual([]); expect(f.calls().some(l => l.startsWith('setup '))).toBe(false); }); @@ -230,16 +231,16 @@ describe('gstack-capy-install', () => { const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim(); const zip = path.join(f.base, 'good.zip'); fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true }); - script(path.join(f.base, `z/bun-${target}/bun`), ['echo 1.4.3']); + script(path.join(f.base, `z/bun-${target}/bun`), [`echo ${BUN_DEFAULT}`]); expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0); - trustZip(f.root, '1.4.3', target, zip); + trustZip(f.root, BUN_DEFAULT, target, zip); const r = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(r.status, r.stdout + r.stderr).toBe(0); - expect(r.stdout).toContain('installing bun 1.4.3 from its release archive'); - expect(r.stdout).toContain(`bun 1.4.3 installed at ${store(f, '1.4.3')}/bun`); - expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (gstack's install default, from gstack's runtime store;`); + expect(r.stdout).toContain(`installing bun ${BUN_DEFAULT} from its release archive`); + expect(r.stdout).toContain(`bun ${BUN_DEFAULT} installed at ${store(f, BUN_DEFAULT)}/bun`); + expect(r.stdout).toContain(`bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (gstack's install default, from gstack's runtime store;`); expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false); - expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.3')); + expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill(`env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=${BUN_DEFAULT}`)); expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); }); @@ -333,15 +334,15 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => { const f = fixture({ bun: '1.3.14' }); - const zip = zipBun(f, '1.4.3'); - const bunDir = store(f, '1.4.3'); + const zip = zipBun(f, BUN_DEFAULT); + const bunDir = store(f, BUN_DEFAULT); const first = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(first.status, first.stdout + first.stderr).toBe(0); expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`); expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`); const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(second.status, second.stdout + second.stderr).toBe(0); - expect(second.stdout).toContain(`bun 1.4.3 at ${bunDir}/bun`); + expect(second.stdout).toContain(`bun ${BUN_DEFAULT} at ${bunDir}/bun`); expect(second.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH `); expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1); expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2); @@ -477,19 +478,19 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti expectLaunch(outside, `${bi}/bin/bun`); }); - // 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']) { + // Integration item 1: gstack's own CI pins 1.4.2 on every lane and the + // install default 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 the install default; nothing is downloaded', () => { + for (const version of ['1.4.2', BUN_DEFAULT]) { 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(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 ${BUN_DEFAULT}))`); 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); @@ -499,9 +500,9 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti 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); + const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, BUN_DEFAULT) }, 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 (`); + expect(r.stdout).toContain(`bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (`); const g = fixture({ bun: '1.3.14' }); withPins(g); realBun(path.join(g.home, '.bun/bin/bun'), '1.4.2'); @@ -546,13 +547,13 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti test('a broken store entry is repaired in place: the version directory is never moved aside, bun is renamed over its predecessor and bunx is published', () => { const f = fixture({ bun: '1.3.14' }); withPins(f); - const dir = store(f, '1.4.3'); + const dir = store(f, BUN_DEFAULT); realBun(path.join(dir, 'bun'), '1.4.0'); const inode = fs.statSync(dir).ino; - const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.base); + const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, BUN_DEFAULT) }, f.base); expect(r.status, r.stdout + r.stderr).toBe(0); expect(fs.statSync(dir).ino).toBe(inode); - expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe('1.4.3'); + expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe(BUN_DEFAULT); expect(fs.readlinkSync(path.join(dir, 'bunx'))).toBe('bun'); expect(fs.readdirSync(path.dirname(dir)).filter(n => n.startsWith('.tmp'))).toEqual([]); }); @@ -588,10 +589,10 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti const unset = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: '' }); expect(unset.status, unset.stdout + unset.stderr).toBe(0); expectLaunch(unset, `${bi}/bin/bun`); - realBun(path.join(store(f, '1.4.3'), 'bun'), '1.4.3'); + realBun(path.join(store(f, BUN_DEFAULT), 'bun'), BUN_DEFAULT); const newer = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi }); expect(newer.status, newer.stdout + newer.stderr).toBe(0); - expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun 1.4.3 at ${store(f, '1.4.3')}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`); + expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`); expectLaunch(newer, `${bi}/bin/bun`); expect(newer.stdout).not.toContain('BUN_RESOLUTION_DRIFT'); expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0); @@ -622,7 +623,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti expect(f.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false); const r = run(f, ['--for', 'autoplan'], { GSTACK_BUN_INSTALL_VERSION: '1.4.2', STUB_CURL_ZIP: zipFor(f, '1.4.2') }); expect(r.status, r.stdout + r.stderr).toBe(0); - expect(r.stdout).toContain('install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default 1.4.3)'); + expect(r.stdout).toContain(`install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default ${BUN_DEFAULT})`); expect(r.stdout).toContain(`bun 1.4.2 at ${store(f, '1.4.2')}/bun (gstack's install default, from gstack's runtime store;`); }); @@ -662,8 +663,8 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire const state = path.join(f.base, 'custom-state'); const storeDir = path.join(state, 'runtimes/bun'); realBun(path.join(storeDir, '1.4.2/bun'), '1.4.2'); - realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3'); - const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', '1.4.3'), c = repo(f, 'c'); + realBun(path.join(storeDir, `${BUN_DEFAULT}/bun`), BUN_DEFAULT); + const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', BUN_DEFAULT), c = repo(f, 'c'); for (const dir of [a, b, c]) { const r = run(f, ['--for', 'review'], { GSTACK_STATE_ROOT: state }, dir); expect(r.status, r.stdout + r.stderr).toBe(0); @@ -673,14 +674,14 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire expect(shim).toContain(`store='${storeDir}'`); const outside = path.join(f.base, 'outside'); fs.mkdirSync(outside); - for (const [dir, want] of [[a, '1.4.2'], [b, '1.4.3'], [a, '1.4.2'], [c, '1.4.3'], [outside, '1.4.3'], [b, '1.4.3']] as const) { + for (const [dir, want] of [[a, '1.4.2'], [b, BUN_DEFAULT], [a, '1.4.2'], [c, BUN_DEFAULT], [outside, BUN_DEFAULT], [b, BUN_DEFAULT]] as const) { const r = fresh(f, dir, 'bun --version'); expect(r.stdout.trim(), `${dir}\n${r.stderr}`).toBe(want); expect(r.stderr).toBe(''); } const explained = fresh(f, path.join(a), 'mkdir -p sub && cd sub && bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); expect(explained.stderr).toMatch(new RegExp(`^gstack bun shim: project=${a} record=${state}/install-verdicts/[0-9a-f]{64}/[0-9a-f]{64}\\.json chose=${storeDir}/1\\.4\\.2/bun rule=record\\n$`)); - expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/1.4.3/bun rule=default\n`); + expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/${BUN_DEFAULT}/bun rule=default\n`); const bunx = fresh(f, a, 'bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); expect(bunx.stderr).toContain(`chose=${storeDir}/1.4.2/bun rule=record`); const unrecordedPinned = repo(f, 'd', '1.4.2'); @@ -690,7 +691,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire // Integration item 6: an unrecorded, unpinned repo falls through quietly; // only GSTACK_BUN_SHIM_EXPLAIN=1 names the missing record. const e = fresh(f, unrecorded, 'bun --version'); - expect([e.stdout.trim(), e.stderr]).toEqual(['1.4.3', '']); + expect([e.stdout.trim(), e.stderr]).toEqual([BUN_DEFAULT, '']); expect(fresh(f, unrecorded, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toContain(`gstack bun shim: ${unrecorded} has no recorded Bun and no exact pin; run ${f.root}/bin/gstack-capy-install --check here to record one\n`); }); @@ -700,7 +701,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); const state = path.join(f.base, 'state'); const storeDir = path.join(state, 'runtimes/bun'); - realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3'); + realBun(path.join(storeDir, `${BUN_DEFAULT}/bun`), BUN_DEFAULT); expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); const pwned = path.join(f.base, 'pwned'); const payload = (dir: string) => script(path.join(dir, 'bun'), [`touch "${pwned}"`, 'echo payload']); @@ -709,15 +710,15 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire write(path.join(evilRepo, '.bun-version'), '../../../repo/payload\n'); expect(fs.existsSync(path.join(storeDir, '../../../repo/payload/bun'))).toBe(true); const t = fresh(f, evilRepo, 'bun --version'); - expect(t.stdout.trim()).toBe('1.4.3'); + expect(t.stdout.trim()).toBe(BUN_DEFAULT); expect(t.stderr).toContain(`${evilRepo} pins bun ../../../repo/payload, which is not in gstack's runtime store`); payload(path.join(f.base, 'evil')); fs.symlinkSync(path.join(f.base, 'evil'), path.join(storeDir, '1.4.9')); const escape = fresh(f, repo(f, 'escape', '1.4.9'), 'bun --version'); - expect(escape.stdout.trim()).toBe('1.4.3'); + expect(escape.stdout.trim()).toBe(BUN_DEFAULT); write(path.join(storeDir, '1.4.8/bun'), '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\necho loop\n', 0o755); const marked = fresh(f, repo(f, 'marked', '1.4.8'), 'bun --version'); - expect(marked.stdout.trim()).toBe('1.4.3'); + expect(marked.stdout.trim()).toBe(BUN_DEFAULT); expect(fs.existsSync(pwned)).toBe(false); }); @@ -732,7 +733,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '' }, outside); expect(r.status, r.stdout + r.stderr).toBe(0); expect(r.stdout).toContain(`bun 1.4.2 at ${f.home}/.bun/bin/bun (~/.bun/bin;`); - expect(fs.existsSync(path.join(f.home, '.gstack-state/runtimes/bun/1.4.3'))).toBe(false); + expect(fs.existsSync(path.join(f.home, `.gstack-state/runtimes/bun/${BUN_DEFAULT}`))).toBe(false); for (const dir of [outside, f.root]) { const e = fresh(f, dir, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); expect(e.stdout.trim(), e.stderr).toBe('1.4.2'); @@ -752,13 +753,13 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire const f = fixture({ bun: '1.3.14' }); fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); const state = path.join(f.base, "it's state"); - realBun(path.join(state, 'runtimes/bun/1.4.3/bun'), '1.4.3'); + realBun(path.join(state, `runtimes/bun/${BUN_DEFAULT}/bun`), BUN_DEFAULT); expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0); const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); expect(r.status, r.stderr).toBe(0); - expect(r.stdout.trim().split('\n')[0]).toBe('1.4.3'); - expect(r.stderr.split(`chose=${state}/runtimes/bun/1.4.3/bun rule=record`), r.stderr).toHaveLength(3); + expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT); + expect(r.stderr.split(`chose=${state}/runtimes/bun/${BUN_DEFAULT}/bun rule=record`), r.stderr).toHaveLength(3); }); test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => { @@ -789,8 +790,8 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire test('the shim adds little over the Bun it runs (fixture timing bound)', () => { const f = fixture(); fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); - const storeBun = path.join(f.home, '.gstack-state/runtimes/bun/1.4.3/bun'); - realBun(storeBun, '1.4.3'); + const storeBun = path.join(f.home, `.gstack-state/runtimes/bun/${BUN_DEFAULT}/bun`); + realBun(storeBun, BUN_DEFAULT); expect(run(f).status).toBe(0); const time = (cmd: string) => { const samples = Array.from({ length: 7 }, () => { const t = performance.now(); fresh(f, f.base, cmd); return performance.now() - t; }).sort((x, y) => x - y);