diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 9cfb037ab..7b65cf9d9 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -227,6 +227,57 @@ jobs: - name: CSO source formatting run: bun run format:cso:check + # Capy machines install GSTACK_BUN_DEFAULT (bin/gstack-bun-version.sh) when + # no project pin decides, above the 1.4.2 minimum every other lane pins, so + # the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT; + # test/bun-version-drift.test.ts checks this job exists and agrees. + quick-bun-default: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.3 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Configure the bundled Chromium sandbox helper + run: | + set -euo pipefail + chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))') + case "$chrome" in + "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;; + *) echo "Unexpected Chromium installation path" >&2; exit 1 ;; + esac + helper="${chrome%/*}/chrome_sandbox" + installed="${chrome%/*}/chrome-sandbox" + test -f "$helper" && test ! -L "$helper" + sudo install -T -o root -g root -m 4755 "$helper" "$installed" + - name: Install Xvfb + X11 utilities + gate tools + run: sudo apt-get install -y --no-install-recommends xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh + - name: Configure git identity (tests init temp repos) + run: | + git config --global user.email "free-tests-ci@gstack.test" + git config --global user.name "Free Tests CI" + git config --global init.defaultBranch main + git config --global --add safe.directory '*' + - name: Generate host SKILL.md outputs (.agents, .factory) + run: bun run gen:skill-docs --host all + - name: Vendor xterm assets into the extension + run: bun run vendor:xterm + - name: Build server-node bundle (loaded by browse cli imports) + run: bash browse/scripts/build-node-server.sh + - name: Build gate binaries (make-pdf e2e gates) + run: bun run build:gates + - name: Build trusted CSO helper used by free contract tests + run: bun run build:cso + - name: Run the quick free tier on the install-default Bun + run: xvfb-run -a bun run test:quick + free-suite: needs: free-plan runs-on: ubicloud-standard-8 @@ -396,14 +447,15 @@ jobs: # gate is merge-blocking without a separate branch-protection migration. free-tests: if: always() - needs: [free-suite, typecheck, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] + needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - name: Require the free suite, typecheck, macOS regressions, and every CSO platform gate + - name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate env: FREE_SUITE_RESULT: ${{ needs.free-suite.result }} TYPECHECK_RESULT: ${{ needs.typecheck.result }} + QUICK_BUN_DEFAULT_RESULT: ${{ needs.quick-bun-default.result }} CSO_MACOS_RESULT: ${{ needs.cso-macos-launcher.result }} MACOS_REGRESSIONS_RESULT: ${{ needs.macos-named-regressions.result }} CSO_WINDOWS_RESULT: ${{ needs.cso-windows-launcher.result }} @@ -412,6 +464,7 @@ jobs: set -eu test "$FREE_SUITE_RESULT" = success test "$TYPECHECK_RESULT" = success + test "$QUICK_BUN_DEFAULT_RESULT" = success test "$CSO_MACOS_RESULT" = success test "$MACOS_REGRESSIONS_RESULT" = success test "$CSO_WINDOWS_RESULT" = success diff --git a/test/bun-version-drift.test.ts b/test/bun-version-drift.test.ts index 55a83348f..724bc337e 100644 --- a/test/bun-version-drift.test.ts +++ b/test/bun-version-drift.test.ts @@ -28,8 +28,13 @@ const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows'); interface Pin { surface: string; version: string; + /** The workflow job the pin sits in (empty outside a workflow). */ + job?: string; } +/** The one job that runs on GSTACK_BUN_DEFAULT instead of the minimum (plan A-g). */ +const DEFAULT_BUN_JOB = 'quick-bun-default'; + function collectPins(): Pin[] { const pins: Pin[] = []; @@ -37,7 +42,9 @@ function collectPins(): Pin[] { if (!/\.ya?ml$/.test(name)) continue; const source = fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8'); const lines = source.split('\n'); + let job = ''; for (let i = 0; i < lines.length; i++) { + job = lines[i].match(/^ {2}([A-Za-z0-9_-]+):\s*$/)?.[1] ?? job; if (!/uses:\s*oven-sh\/setup-bun@/.test(lines[i])) continue; // A pinned stanza is `with:` + `bun-version: ` within the next few // lines; an unpinned setup-bun is itself drift (installs latest). @@ -46,6 +53,7 @@ function collectPins(): Pin[] { pins.push({ surface: `${name}:${i + 1}`, version: m ? m[1] : '', + job, }); } } @@ -69,8 +77,8 @@ function collectPins(): Pin[] { } describe('bun version pins', () => { - test('every CI surface pins the same bun version', () => { - const pins = collectPins(); + test('every CI surface pins the same bun version (the install-default job aside)', () => { + const pins = collectPins().filter(p => p.job !== DEFAULT_BUN_JOB); // Sanity: the scan found the known surfaces (a regex rot that finds // nothing must fail loudly, not vacuously pass). expect(pins.length).toBeGreaterThanOrEqual(6); @@ -96,19 +104,40 @@ describe('bun version pins', () => { }); }); -function bunVersionsFile(): { floor: string; tested: string } { +function bunVersionsFile(): { floor: string; tested: string; installDefault: string; verified: string[] } { const src = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-bun-version.sh'), 'utf-8'); return { floor: src.match(/^GSTACK_BUN_FLOOR="([^"]+)"$/m)?.[1] ?? '', tested: src.match(/^GSTACK_BUN_TESTED="([^"]+)"$/m)?.[1] ?? '', + installDefault: src.match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)?.[1] ?? '', + verified: (src.match(/^GSTACK_BUN_VERIFIED="([^"]+)"$/m)?.[1] ?? '').split(/\s+/).filter(Boolean), }; } +// Plan A2/A-g: the Capy install default is separate from the floor. It must be +// a verified archive at or above the minimum, and exactly one CI job (the +// quick free tier) runs on it so the default Bun is never untested. +describe('install default Bun (A2, A-g)', () => { + const { tested, installDefault, verified } = bunVersionsFile(); + test('GSTACK_BUN_DEFAULT is a verified archive and not below the supported minimum', () => { + expect(verified).toContain(installDefault); + expect(verified).toContain(tested); + expect(Bun.semver.order(installDefault, tested)).toBeGreaterThanOrEqual(0); + }); + test('one CI job runs the quick free tier on GSTACK_BUN_DEFAULT, and the free-tests aggregate requires it', () => { + const onDefault = collectPins().filter(p => p.job === DEFAULT_BUN_JOB); + expect(onDefault.map(p => p.version)).toEqual([installDefault]); + const workflow = Bun.YAML.parse(fs.readFileSync(path.join(WORKFLOWS_DIR, 'free-tests.yml'), 'utf-8')) as { jobs: Record }; + expect(workflow.jobs[DEFAULT_BUN_JOB].steps.some(step => step.run?.includes('bun run test:quick'))).toBe(true); + expect(workflow.jobs['free-tests'].needs).toContain(DEFAULT_BUN_JOB); + }); +}); + describe('Bun requirement surfaces agree (E1)', () => { const { floor, tested } = bunVersionsFile(); - test('tested version == every CI pin == engines.bun lower bound; floor sits below it', () => { - const ciVersions = [...new Set(collectPins().map((p) => p.version))]; + test('tested version == every CI pin (the install-default job aside) == engines.bun lower bound; floor sits below it', () => { + const ciVersions = [...new Set(collectPins().filter((p) => p.job !== DEFAULT_BUN_JOB).map((p) => p.version))]; expect(ciVersions).toEqual([tested]); const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); expect(pkg.engines?.bun).toBe(`>=${tested}`); diff --git a/test/cso-distribution.test.ts b/test/cso-distribution.test.ts index 2233d2a54..dd0417e6b 100644 --- a/test/cso-distribution.test.ts +++ b/test/cso-distribution.test.ts @@ -388,7 +388,7 @@ describe('CSO runtime staging gates', () => { expect(gate['continue-on-error']).not.toBe(true); const required = workflow.jobs['free-tests']; expect(required.if).toBe('always()'); - expect(required.needs).toEqual(['free-suite', 'typecheck', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']); + expect(required.needs).toEqual(['free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']); expect(required.steps[0].run).toContain('test "$CSO_DOCKER_RESULT" = success'); for (const current of Object.values(workflow.jobs) as any[]) for (const step of current.steps) { if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6');