Files
gstack/.github/workflows/free-tests.yml
T
Garry Tanandcapy-ai[bot] 5755601dac v1.91.248.0 fix(browse): direct mode launches Chromium with --proxy-server=direct:// so proxy env vars never win silently (#3440 item 8) (#3448)
* fix(browse): direct mode launches Chromium with --proxy-server=direct:// so proxy env vars never win silently (#3440 item 8)

BROWSE_PROXY_URL=direct only stopped gstack from configuring a proxy; the daemon
inherits the environment and the launch passed no proxy switch, so Chromium read
HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself and every goto still went through that
proxy while browse reported a direct connection. Every launch path now adds
--proxy-server=direct:// when no proxy is configured (chrome-headless-shell
ignores --no-proxy-server), the daemon logs the effective proxy mode on launch,
and BROWSER.md's proxy section says what Chromium actually gets.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Name the direct-connection switch once (DIRECT_PROXY_ARG in proxy-config.ts) so the
launch argument and the daemon log line cannot drift apart.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): force direct:// only where Chromium would pick its own proxy; review fixes

Red team (ship Step 9.2): the first cut passed --proxy-server=direct:// on every
platform, but Chromium reads HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself only on
Linux. On macOS and Windows it follows the system proxy settings (PAC/WPAD),
which GStack Browser.app users behind a corporate proxy depend on, and
BROWSE_PROXY_URL cannot name a PAC. forcesDirect(env, platform) now gates the
switch: Linux always, elsewhere only with BROWSE_PROXY_URL=direct. The daemon
log says what is really ignored per platform, or that the system settings
apply.

Review pass 2: the CLI e2e asserts on the daemon-log slice its own invocation
wrote (the log is append-mode and shared across cases); connectProxy moves to
browse/test/helpers/connect-proxy.ts for both e2e files; the CHANGELOG Tests
bullets cover the generated cases. Adds the coverage-audit cases
(unparseable HTTPS_PROXY; extension, headed and handoff paths under Xvfb).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): assert the direct switch on the launched Chromium command line per platform (#3440 item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship review pass 3 (red team, GStack Minder's decision B, 2026-10-26): on macOS
and Windows an explicit BROWSE_PROXY_URL=direct is the only thing that makes the
daemon bypass the system proxy settings, so explicitDirect() puts it in the
daemon config hash, server.ts records it as proxyDirect in the state file, and
buildRestartEnv re-emits it on a crash auto-restart. A reused daemon started on
the system proxy is now refused (the refusal names BROWSE_PROXY_URL and offers
browse stop or unsetting it) instead of silently keeping the system proxy while
the CLI reports direct. Linux hashes as before. BROWSER.md says the daemon log
records the mode each time the daemon starts, not on every launch. The Linux
desktop (GNOME/KDE) system-proxy question is filed as #3450.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): daemon-state and refusal e2e for the explicit direct opt-out; headed env and BROWSER.md contract checks (#3440 item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship rerun review pass: proxy-direct-launch-args.test.ts joins the macOS
named-regressions job so its omission arm (no switch without the opt-out) runs
in CI; serverProxyEnv takes the platform so the state-file predicate is pinned
per platform instead of mirroring process.platform; buildHeadedServerEnv's
parameter type requires explicitDirect so a narrowed caller cannot drop the
opt-out silently; BROWSER.md's precedence item 6 and the reuse sentence say
what each platform does (a connect daemon gets the generic flags refusal);
CHANGELOG notes the one-time refusal against a pre-release daemon.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship adversarial pass: a crash restart re-emits BROWSE_PROXY_URL=direct from the
daemon state only when the invocation has no flags, so the env it hands the new
daemon never contradicts the config hash it also hands over; an explicit-direct
session is refused against a daemon with no recorded config hash, like --proxy
and --headed already were; the unparseable-variable hint says that the opt-out
also bypasses the system proxy settings on macOS and Windows; BROWSER.md notes
that a managed Chromium proxy policy still overrides the switch; CHANGELOG names
both files that joined the macOS named-regressions job.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): read scheme-less proxy variables as http:// so the explicit direct launch keeps those sessions proxied (#3440 item 8)

A standard proxy variable without a scheme (curl-style host:port or
user:pass@host:port) was rejected by browse's parser with a "connects
directly" warning while Chromium on Linux read the variable itself, so the
session was proxied anyway. With the launch now explicit
(--proxy-server=direct://) that environment went genuinely direct. resolveProxy
now reads such values as http://, the way curl and Chromium's own environment
reader take them; --proxy and BROWSE_PROXY_URL still require the scheme, and a
value that does not parse even as http:// still warns and connects directly.

Also drops buildRestartEnv's persisted-state re-emit branch, which production
never reached (_globalFlags is set before any restart), names the four
BrowserManager launch sites in the CHANGELOG and excludes the cookie-import
worker, and carries the docs audit's one-line BROWSER.md project-tree note.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

The lenient scheme-less proxy read is now gated on the plain
[user:pass@]host[:port] shape (SCHEMELESS_PROXY_RE, trimmed): a scheme'd value
with surrounding whitespace, a one-slash typo and a bare number keep the
behaviour they had, and a credential that would spill past the userinfo
(?, #, /) is not rewritten, so nothing can land outside the redacted part.
Credentials that are not valid percent-encoding are a ProxyConfigError for
every source instead of a URIError crash. buildRestartEnv's docblock, BROWSER.md
and the CHANGELOG describe the parse (port 80 default, macOS/Windows now treat
such a value as a configured proxy).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

A backslash is excluded from the scheme-less proxy shape and parseProxyConfig
refuses any proxy URL with a path, query or fragment, for every source: WHATWG
parsing ends an http authority at a backslash, so a DOMAIN\user login or a /
in a password landed in the path, which the redactor does not mask, and was
printed in the clear on the start line, in the daemon log and in the state
file. Such values now warn (standard variables) or error (--proxy,
BROWSE_PROXY_URL) without echoing the credential. Docs updated.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): keep a slash-less scheme spelling, refuse a bare ? or #, and tighten the proxy docs

A standard proxy variable whose userinfo opens with a known scheme
(https:bob:pw@host:3128, which the URL parser reads as https://) is no
longer taken as scheme-less credentials, so the protocol it names is kept.
parseProxyConfig refuses a literal ? or # anywhere in the value, not only
a non-empty query or fragment. The unparseable-variable test now puts the
credential in every looped value, so its no-echo assertion is real; new
cases pin the slash-less scheme spelling, the bare ?/#, an env-sourced
trailing slash, and that mixed credential sources stay a hard error for
the scheme-less spelling as for the http:// one (the cred-mixing refusal
predates this branch and its test on main pins it, so it is documented
rather than softened).

CHANGELOG and BROWSER.md: single backslashes inside code spans, "a path
other than a bare /", the socks5 bridge log lines in the effective-mode
list, and the character list applies anywhere in the value.

Decision: GStack Minder (2026-10-26), option B.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): keep a basic-auth URL out of the scheme-guard comment so the pregate secret scan stays clean

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): pin the mismatch remedy per platform so the Windows subset does not take process.platform

proxyMismatchLines defaulted to process.platform in the env-proxy-meets-direct-daemon case, so on the windows-free-tests lane the remedy read 'or unset HTTPS_PROXY' and the assertion failed. The case now passes 'linux' explicitly and pins the darwin/win32 remedy beside it. Test only; source unchanged since 1f6c85af.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): the one-slash http:/host:3128 spelling was never a diagnostic; say what the parser does with it

The CHANGELOG sentence and the SCHEMELESS_PROXY_RE docblock claimed a one-slash scheme 'keeps its diagnostic'; the URL parser has always read it as http://host:3128/ and browse/test/proxy-env.test.ts:83 pins that. Wording only, found by the Step 14.5 docs audit on 1f6c85af.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): percent-encoding advice per scheme, and state the credential-free-path narrowing and the new scheme-less cred-mix refusal

BROWSER.md said to percent-encode / \ ? # @ in http(s) credentials; ? and # are refused for every scheme, @ needs no encoding, and only the backslash is http(s)-specific. The CHANGELOG now states the narrowing that a credential-free standard-variable value with a path (http://proxy.corp:3128/pac) used to be read with the path dropped and now warns and goes direct, and that the cred-mix refusal is new for the scheme-less spelling. Wording only.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): only host:port was Chromium's own reading; user:pass@host:port is newly proxied

Chromium's proxy spec parser (net/base/proxy_string_util.cc, ProxySchemeHostAndPortToProxyServer) returns an invalid server for any userinfo, so a scheme-less user:pass@host:port in HTTPS_PROXY was never honoured by Chromium on Linux and went direct; reading it as http:// follows curl and is new behaviour. BROWSER.md, the CHANGELOG, the resolveProxy docblock and the Linux e2e test comment now say so, and the scheme-less spill test is named for what it proves (those values never reach the path refusal; the http:// cases in the next test do). Wording only.

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-26 13:56:12 +00:00

558 lines
26 KiB
YAML

name: Free Tests
# A single duration-balanced plan covers every free test exactly once. Each
# shard runs serially on its own machine; the aggregate requires every receipt
# and strict outcome. Tree-mutating files, when present, get a separate machine.
#
# Deliberately SECRETLESS: free tests make no API calls, so this lane gets no
# provider keys at all — least privilege, and fork PRs get real test signal
# here (the eval matrix skips fork PRs because repository secrets can't reach
# them). test/free-tests-workflow-wiring.test.ts fails CI if a secret sneaks in.
#
# This is a REQUIRED check from day one (branch protection lists it). If it's
# red, fix or quarantine-with-issue — don't make it advisory; an advisory lane
# is permanent false comfort.
#
# Local `bun run test` still uses the existing bounded process pool.
on:
pull_request:
branches: [main]
# Also on main pushes: two individually-green PRs can merge into a red
# main; without this nothing runs the free suite on main until the next PR.
push:
branches: [main]
workflow_dispatch:
# Keyed on the PR number, not head_ref: a bare branch name carries no fork
# prefix, so same-name branches from two forks would share one group and a
# push to fork B's PR would cancel fork A's in-flight REQUIRED check.
concurrency:
group: free-tests-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
# Least privilege: this job executes PR-controlled code (install lifecycle
# scripts + the test suite), so the GITHUB_TOKEN gets read-only contents and
# the checkout doesn't persist it into .git/config.
permissions:
contents: read
jobs:
free-plan:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# Full history: the seed ratchet compares the allowlist with its
# merge-base copy, which a depth-1 checkout cannot read.
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- id: plan
name: Inventory and balance the complete free suite
# Warns in the job summary when more than 5 files are unseeded.
run: |
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards 20)
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
run: |
GSTACK_FREE_SEED_BASE="$(git merge-base HEAD origin/main)" bun test test/free-seed-ratchet.test.ts
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: free-plan
path: ${{ runner.temp }}/free-plan.json
if-no-files-found: error
cso-macos-launcher:
runs-on: macos-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Install helper dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Build and sign the native macOS startup boundary
run: bun run build:cso
- name: Exercise hardened-runtime startup and environment scrubbing
run: bun run test:cso:macos
env:
GSTACK_CSO_MACOS_TESTS: "1"
- name: Build browser lifecycle prerequisites
env:
GSTACK_STRICT_BUILD: '1'
run: |
bunx playwright install chromium
bun run build
- name: Exercise native agent ownership and linked settings
env:
TMPDIR: /tmp
run: |
files=(browse/test/terminal-agent-lifecycle.test.ts browse/test/terminal-agent-native-observation.test.ts browse/test/terminal-agent-watchdog.test.ts browse/test/server-embedder-terminal-port.test.ts browse/test/server-factory.test.ts test/gstack-settings-hook-symlink.test.ts test/gstack-settings-hook-schema-aware.test.ts)
for file in "${files[@]}"; do test -f "$file"; done
bun test "${files[@]}"
# Named regressions that only reproduce on macOS's default temp root:
# $TMPDIR sits under /var/folders and /var is a symlink to /private/var, so
# code that compares a realpath with an unresolved path passes on Linux's
# /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's
# default, and the job fails if that layout is not symlinked. It also sources
# the Codex probe from zsh, macOS's default shell (#3024; no paid call).
macos-named-regressions:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Build browser prerequisites
env:
GSTACK_STRICT_BUILD: '1'
run: |
bunx playwright install chromium
bun run build
- 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
- name: Codex probe selects a model from zsh, run as a command and sourced (macOS default shell, no paid call)
working-directory: ${{ runner.temp }}
run: |
set -euo pipefail
run="$(zsh -c '"$GITHUB_WORKSPACE/bin/gstack-codex-probe" select-model exec' 2>&1)"
printf '%s\n' "$run"
printf '%s\n' "$run" | grep -E '^CODEX_SEL: [A-Za-z0-9._:/-]+$'
out="$(zsh -c 'source "$GITHUB_WORKSPACE/bin/gstack-codex-probe" && _gstack_codex_select_model exec' 2>&1)"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep -E '^CODEX_MODEL: [A-Za-z0-9._:/-]+ \(exec; source: '
- name: Run the named macOS regressions on the default temp root
run: |
set -euo pipefail
tmp="${TMPDIR:-/tmp}"
resolved="$(realpath "$tmp")"
echo "temp root: $tmp -> $resolved"
test "$resolved" != "${tmp%/}"
files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts)
for file in "${files[@]}"; do test -f "$file"; done
bun test "${files[@]}"
cso-windows-launcher:
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Install helper dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Build the native Windows startup boundary from Git Bash
shell: bash
run: bun run build:cso
- name: Exercise native startup, environment, and argument forwarding
run: bun run test:cso:windows
env:
GSTACK_CSO_WINDOWS_TESTS: "1"
- name: Upload #2894 lease diagnostics
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: cso-lease-diagnostics-a${{ github.run_attempt }}
path: ${{ runner.temp }}/cso-lease-diagnostics
if-no-files-found: ignore
retention-days: 14
cso-docker-integration:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Require local Docker containment prerequisites
# A missing daemon is a failed gate, never a skipped integration test.
run: |
command -v docker
docker --host unix:///var/run/docker.sock info
command -v cc
- name: Install helper dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Compile trusted CSO helper and watchdog
run: bun run build:cso
# Only the file that can run here: the node-lifecycle and stack-cold
# files need GSTACK_CSO_TEST_STACK images and run in the CSO runtime
# workflows (test:cso:docker), so in this job they always skipped.
- name: Run CSO Docker integration gate
run: |
bun test --max-concurrency 1 test/cso-docker-integration.test.ts
env:
GSTACK_CSO_DOCKER_TESTS: "1"
DOCKER_HOST: unix:///var/run/docker.sock
typecheck:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Typecheck product code (zero errors)
run: bun run typecheck
- name: Test-code type-debt ratchet
run: bun run typecheck:test
- 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: ubicloud-standard-8
timeout-minutes: 30
steps:
- 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/<nonce>. 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.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: |
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
env:
GSTACK_FREE_RETRY_FLAKY: "1"
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
run: xvfb-run -a bun run test:quick
free-suite:
needs: free-plan
runs-on: ubicloud-standard-8
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 20
matrix: ${{ fromJSON(needs.free-plan.outputs.matrix) }}
steps:
- 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/<nonce>. 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
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: free-plan
path: ${{ runner.temp }}
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.bun/install/cache
key: linux-bun-${{ hashFiles('bun.lock') }}
# A lockfile bump starts from the previous cache instead of cold.
restore-keys: |
linux-bun-
- 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: |
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"
test "$(stat -c '%u:%a' "$installed")" = '0:4755'
cmp -s "$helper" "$installed"
# Headed-browser tests (handoff, extension sidepanel DOM) need a real
# DISPLAY — first Linux run failed with Playwright's "launched a headed
# browser without an XServer" banner. xvfb-run below provides it;
# x11-utils ships xdpyinfo for display probing. poppler-utils ships
# pdftotext/pdffonts/pdftoppm for the make-pdf e2e gates;
# fonts-noto-color-emoji is the emoji-gate's render font (playwright
# --with-deps usually installs it, but the gate must not depend on a
# transitive package list). Fonts must land BEFORE the first browse
# daemon launch — Chromium snapshots fontconfig at startup. zsh is not
# in the runner image; without it the bash+zsh portability arms (#2669)
# skip instead of running.
- 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
# Some tests run git against the checkout itself; CI checkouts can be
# owned by a different uid than the runner user.
git config --global --add safe.directory '*'
- name: Generate host SKILL.md outputs (.agents, .factory)
# Golden-file tests read generated host outputs that are gitignored.
run: bun run gen:skill-docs --host all
- name: Vendor xterm assets into the extension
# extension/lib/xterm* are gitignored (vendored from npm at build
# time). Without them the sidepanel's terminal script bails and the
# sidepanel DOM tests time out waiting on init that never happens.
run: bun run vendor:xterm
- name: Build server-node bundle (loaded by browse cli imports)
run: bash browse/scripts/build-node-server.sh
# Narrowed gate build: the make-pdf e2e gates probe make-pdf/dist/pdf,
# browse/dist/browse, and the diagram-render bundle, then self-skip when
# absent — which made them silently skip on Linux for their whole life
# (this lane never built binaries). Full `bun run build` compiles five
# binaries and would add ~60-90s to the ONLY required check; the gates
# need exactly these three artifacts.
- 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
# GSTACK_EXPECT_BINARIES=1 arms make-pdf/test/e2e/ci-prereqs.test.ts:
# if a future edit drops the gate build (or poppler), the lane FAILS
# instead of the gates silently self-skipping back to false green.
- name: Run free suite
run: xvfb-run -a bun run test:free --ci-run "$RUNNER_TEMP/free-plan.json" --shard ${{ matrix.shard }} --result "$RUNNER_TEMP/free-results/shard-${{ matrix.shard }}.json"
env:
GSTACK_EXPECT_BINARIES: "1"
# WS1 flake telemetry: a single timing flake must not red the only
# required lane — the runner's attribution-gated retry pass (cap 5,
# truncation veto) re-runs failing files once, serially, and a
# clean retry downgrades to a LOUD flaky-pass. Every flaky-pass is
# appended to the ledger (single writer: the parent runner) and
# uploaded below, so repeat offenders are an enumerable series —
# recorded and ranked, never masked. Pinned by
# test/free-tests-workflow-wiring.test.ts.
GSTACK_FREE_RETRY_FLAKY: "1"
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
- name: Upload strict shard result
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: free-result-${{ matrix.shard }}
path: ${{ runner.temp }}/free-results/*.json
if-no-files-found: error
# Uploaded unconditionally (not just on failure): a flaky-pass run is
# GREEN — that's the point — so its evidence must survive green runs.
- name: Upload flake ledger
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: flake-ledger-${{ matrix.shard }}
path: ${{ runner.temp }}/flake-ledger.jsonl
if-no-files-found: ignore
retention-days: 90
- name: Detect recovered failures for log retention
id: flake_spool
if: always()
run: |
if [ -s "$RUNNER_TEMP/flake-ledger.jsonl" ]; then
echo 'present=true' >> "$GITHUB_OUTPUT"
fi
# The quiet console omits assertion details. Preserve the original spool
# after a recovered retry too, so a green job retains its first failure.
- name: Upload shard logs on failure or recovered retry
if: failure() || steps.flake_spool.outputs.present == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: free-test-shard-logs-${{ matrix.shard }}
path: .context/free-test-logs/gstack-free-test-*.log
include-hidden-files: true
if-no-files-found: ignore
# Branch protection already requires the `free-tests` context. Keep that
# stable name as an always-running aggregate so every platform-specific CSO
# gate is merge-blocking without a separate branch-protection migration.
free-tests:
if: always()
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, 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 }}
CSO_DOCKER_RESULT: ${{ needs.cso-docker-integration.result }}
run: |
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
test "$CSO_DOCKER_RESULT" = success
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: free-plan
path: ${{ runner.temp }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
pattern: free-result-*
merge-multiple: true
path: ${{ runner.temp }}/free-results
# Per-shard ledgers land in their own subdirectories (all are named
# flake-ledger.jsonl). A green run is where a retried flake hides, so the
# summary runs before strict verification.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
pattern: flake-ledger-*
path: ${{ runner.temp }}/flake-ledgers
- name: Summarize flaky passes absorbed by the serial retry
run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY"
- name: Require exact coverage and complete strict results
run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results"