diff --git a/.github/workflows/free-durations-record.yml b/.github/workflows/free-durations-record.yml index 9101104d3..19d34f289 100644 --- a/.github/workflows/free-durations-record.yml +++ b/.github/workflows/free-durations-record.yml @@ -161,7 +161,7 @@ jobs: - Files that failed while recording (durations kept; this PR's own free checks decide): $failed_line - Seed growth ratchet (60 s ceiling, shrink-only allowlist): $ratchet_line - Data only. This run dispatched Free Tests (the required free-tests check), Windows Free Tests and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks. + Data only. This run dispatched Free Tests (the required free-tests check), Windows Smoke and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks. EOF if [ -n "$number" ]; then gh pr edit "$number" --body-file pr-body.md @@ -169,5 +169,5 @@ jobs: gh pr create --base main --head "$branch" --title "test(seed): refresh the free-suite duration seeds" --body-file pr-body.md fi gh workflow run free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" - gh workflow run windows-free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" + gh workflow run windows-smoke.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" gh workflow run quality-gate.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 6d19e5b48..9d0ea1763 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -1,9 +1,15 @@ name: Windows Free Tests -# The Windows-safe subset of the free suite on GitHub's free `windows-latest` -# runners. scripts/test-free-shards.ts --windows-only is the single source of -# truth for which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp -# paths, /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by +# Nightly, not a PR gate. Windows has minimal support (docs/support-tiers.md): +# the required Windows check is windows-smoke.yml (./setup plus the core +# skills). This workflow runs the curated Windows-safe subset of the free suite +# on main every night and on workflow_dispatch, and never blocks a pull +# request. A red nightly run gets a fix only when it shows an install, startup +# or core-workflow break; other Windows-only failures close as won't fix. +# +# scripts/test-free-shards.ts --windows-only is the single source of truth for +# which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp paths, +# /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by # pattern there, with explicit force-include and known-incompatible lists. # Every run prints the curated count and top exclusion reasons in the job # summary so a shrinking subset is visible. @@ -21,13 +27,9 @@ name: Windows Free Tests # diagnostics) are dispatch-only in native-qualification.yml. on: - pull_request: - branches: [main] - # Also on main pushes, as free-tests.yml does: trains dispatch only the - # Linux free workflow, so without this no Windows run ever sees a train's - # combined tree or main after a merge. - push: - branches: [main] + # 09:17 UTC, about 2 AM Pacific, on the default branch (main). + schedule: + - cron: '17 9 * * *' workflow_dispatch: inputs: record_durations: @@ -79,6 +81,9 @@ jobs: if-no-files-found: error windows-free-shard: + # gstack-pregate reads a job keyed on a dispatch input as manual, never a + # PR obligation; this lane runs only nightly or by hand. + if: ${{ !inputs.record_durations }} needs: windows-plan runs-on: windows-latest timeout-minutes: 15 @@ -104,14 +109,8 @@ jobs: name: windows-plan path: ${{ runner.temp }} - # bun's install cache lives under ~/.bun/install/cache on every platform. - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.bun/install/cache - key: windows-bun-${{ hashFiles('bun.lock') }} - # A lockfile bump starts from the previous cache instead of cold. - restore-keys: | - windows-bun- + # No bun install cache: restoring the ~450 MB entry took 33-42 s per + # shard, a cold install 16-17 s (GSTA-203 PR 1 A/B, 3 runs per arm). - name: Configure git identity (required by tests that init temp repos) run: | diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 6b2b928fe..2ca74e7b4 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -92,16 +92,8 @@ jobs: with: bun-version: 1.4.2 - # Same lockfile-keyed install cache as windows-free-tests.yml (install - # was 45s of a 64s job, all network). - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.bun/install/cache - key: windows-bun-${{ hashFiles('bun.lock') }} - # A lockfile bump starts from the previous cache instead of cold - # (restore alone costs ~43s; without this a bump pays it for nothing). - restore-keys: | - windows-bun- + # No bun install cache, as in windows-free-tests.yml: a cold install is + # faster on windows-latest than restoring the ~450 MB cache entry. - name: Configure git identity run: | diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml new file mode 100644 index 000000000..525f50553 --- /dev/null +++ b/.github/workflows/windows-smoke.yml @@ -0,0 +1,170 @@ +name: Windows Smoke + +# The required Windows check under minimal support (docs/support-tiers.md): +# with a default Git for Windows install, ./setup installs and the core skills +# start. One windows-latest job, under 5 minutes: +# 1. ./setup --host claude into an isolated HOME under Git Bash, with +# gstack's own browser (Playwright Chromium). +# 2. The core skills start: gstack-skill-start for /ship, /review, +# /qa-only, /investigate and /browse answers SKILL_START_PROTO: 1. +# 3. /browse on gstack's own browser: the installed browse runs a piped +# chain flow against a local page from an unrelated directory. +# 4. The fastest Windows-safe free tests for those paths (list below). +# The full curated Windows suite is windows-free-tests.yml: nightly on main +# and on dispatch, never a PR gate. +# +# No bun install cache: a cold install took 16-17 s on windows-latest, a +# restore of the ~450 MB cache 33-42 s (GSTA-203 PR 1 A/B, 3 runs per arm). + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: windows-smoke-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + windows-smoke: + 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: Configure git identity (required by tests that init temp repos) + run: | + git config --global user.email "windows-smoke@gstack.test" + git config --global user.name "Windows Smoke" + git config --global init.defaultBranch main + shell: bash + + - name: Install dependencies + run: bun install --frozen-lockfile + shell: bash + + - name: ./setup installs into an isolated HOME + run: | + set -euo pipefail + SMOKE_HOME="$RUNNER_TEMP/smoke-home" + mkdir -p "$SMOKE_HOME" + HOME="$SMOKE_HOME" USERPROFILE="$(cygpath -w "$SMOKE_HOME")" ./setup --host claude + test -x "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" || (echo "MISSING: installed gstack-skill-start" && exit 1) + shell: bash + + - name: The core skills start + run: | + set -euo pipefail + SMOKE_HOME="$RUNNER_TEMP/smoke-home" + USERPROFILE="$(cygpath -w "$SMOKE_HOME")" + export HOME="$SMOKE_HOME" USERPROFILE + for skill in ship review qa-only investigate browse; do + set +e + OUT=$(timeout 120 "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" --skill "$skill" --model claude 2>&1 < /dev/null) + CODE=$? + set -e + echo "::group::$skill"; echo "$OUT"; echo "::endgroup::" + test "$CODE" -eq 0 || (echo "/$skill: gstack-skill-start exited $CODE" && exit 1) + grep -q '^SKILL_START_PROTO: 1' <<< "$OUT" || (echo "/$skill: no SKILL_START_PROTO: 1 line" && exit 1) + done + shell: bash + + - name: /browse on gstack's own browser runs a piped chain flow from an unrelated directory + run: | + set -euo pipefail + SMOKE_HOME="$RUNNER_TEMP/smoke-home" + USERPROFILE="$(cygpath -w "$SMOKE_HOME")" + export HOME="$SMOKE_HOME" USERPROFILE + BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse.exe" + [ -f "$BROWSE" ] || BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse" + test -f "$BROWSE" || (echo "MISSING: installed browse" && exit 1) + bun -e 'Bun.serve({ hostname: "127.0.0.1", port: 47124, fetch: () => new Response("gstack smoke

gstack-windows-smoke-ok

", { headers: { "content-type": "text/html" } }) }); setInterval(() => {}, 1 << 30);' & + PAGE_PID=$! + trap 'kill "$PAGE_PID" 2>/dev/null || true; "$BROWSE" stop >/dev/null 2>&1 || true' EXIT + mkdir -p "$RUNNER_TEMP/unrelated-dir" && cd "$RUNNER_TEMP/unrelated-dir" + set +e + OUT=$(printf '%s\n' '[["goto","http://127.0.0.1:47124/"],["text"]]' | "$BROWSE" chain 2>&1) + CODE=$? + set -e + echo "$OUT" + if [ "$CODE" -ne 0 ]; then + find "$RUNNER_TEMP/unrelated-dir" "$SMOKE_HOME" -maxdepth 4 \( -name 'browse-daemon.log' -o -name 'browse-startup-error.log' \) -print -exec tail -n 60 {} \; 2>/dev/null || true + echo "browse chain exited $CODE" && exit 1 + fi + grep -q 'gstack-windows-smoke-ok' <<< "$OUT" || (echo "chain output lacks the page text: the flow did not run" && exit 1) + shell: bash + + - name: Fastest Windows-safe free tests for install and the core skills + # Windows-measured seconds from scripts/free-test-durations-windows.json. + # setup-windows-fallback (0.1): setup's _link_or_copy copies, never links, on Windows + # setup-staged-copy (0.6): a copy install is staged and swapped in only when complete + # setup-windows-rerun-refresh (1.7): re-running ./setup refreshes copied skills (#2444) + # setup-skill-copies (0.1): copied SKILL.md hashes and the backup of an edited copy + # setup-bun-cmd-and-pipe-bugs (0.4): setup's bun.cmd and pipe handling + # gstack-launch-probe (0.1): setup's Smart App Control launch probe (#2595) + # state-root-parity (2.1): every skill start resolves one state root in TS and bash + # bun-isolation-launch (1.1): Bun entries ignore .env and bunfig under Git Bash (#3415) + # gstack-lease-git-bash (3.7): the skill-start lease under Git Bash (#3330) + # gstack-wtree-windows-paths (1.3): /review and /ship record a start on C:/ paths (#3101) + # ship-hook-windows-paths (2.3): /ship's pre-push guard in a Windows worktree (#2805) + # qa-only-browser-probe (2.4): /qa-only's browser probe fixture + # find-browse (0.1): /browse resolves its binary (.exe) + # cli-chain-stdin (0.3): /browse chain reads its flow from stdin on Windows (#3039) + # windows-spawn-hide (0.1): /browse's spawns hide their console window (#1835) + env: + GSTACK_FREE_RETRY_FLAKY: '1' + GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} + run: | + set -euo pipefail + cat > "$RUNNER_TEMP/windows-smoke-files.txt" <<'LIST' + test/setup-windows-fallback.test.ts + test/setup-staged-copy.test.ts + test/setup-windows-rerun-refresh.test.ts + test/setup-skill-copies.test.ts + test/setup-bun-cmd-and-pipe-bugs.test.ts + test/gstack-launch-probe.test.ts + test/state-root-parity.test.ts + test/bun-isolation-launch.test.ts + test/gstack-lease-git-bash.test.ts + test/gstack-wtree-windows-paths.test.ts + test/ship-hook-windows-paths.test.ts + test/qa-only-browser-probe.test.ts + browse/test/find-browse.test.ts + browse/test/cli-chain-stdin.test.ts + browse/test/windows-spawn-hide.test.ts + LIST + bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt" + shell: bash + + - name: Upload full test logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: windows-smoke-logs + path: | + .context/free-test-logs/gstack-free-test-*.log + ${{ runner.temp }}/gstack-free-test-*.log + include-hidden-files: true + if-no-files-found: ignore + + - name: Upload flake ledger + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: flake-ledger-windows-smoke + path: ${{ runner.temp }}/flake-ledger.jsonl + if-no-files-found: ignore + retention-days: 90 diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 0454e74f3..b275c5bdf 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -16,8 +16,7 @@ "supabase/**": [] }, "selection": { - "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list", - "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" + "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list" }, "warn": [], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index 2ae73849b..a37b11ca4 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -14,6 +14,6 @@ "pr_count": { "default_pr_count": 1, "split_requires_owner": true }, "maintainers": ["garrytan"], "train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 }, - "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, + "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, "min_gstack_version": "1.91.340.0" } diff --git a/AGENTS.md b/AGENTS.md index abe32fea9..25394ab30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,9 +282,11 @@ Agents poll `eval:bg:*` logs for the `### gstack-detach EXIT= ###` sentine ## Platform support - **macOS** + **Linux**: full test suite supported. -- **Windows**: the curated Windows-safe subset runs in the `windows-free-tests` - CI workflow across six `windows-latest` jobs, packed by Windows-measured - durations. Setup script (`./setup`) requires Git Bash or +- **Windows**: minimal support; [docs/support-tiers.md](docs/support-tiers.md) + says what is promised. Every PR must pass the `windows-smoke` check (`./setup`, + the core skills start, the fastest Windows-safe tests); the curated + Windows-safe subset runs nightly on main in `windows-free-tests` and never + blocks a PR. Setup script (`./setup`) requires Git Bash or MSYS today; native PowerShell support is a future expansion. The `bin/gstack-paths` helper resolves state roots through `CLAUDE_PLUGIN_DATA` / `GSTACK_HOME` so plugin installs work on every platform. diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index 634336631..5843d65be 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1543,7 +1543,7 @@ refresh to a maintainer. (`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds or drops a test file, it force-updates the `bot/free-duration-seeds` branch, opens or refreshes one seed PR whose body states the ratchet result, and - dispatches Free Tests, Windows Free Tests and Quality gate on it. Those + dispatches Free Tests, Windows Smoke and Quality gate on it. Those dispatched runs are the seed PR's checks, and Free Tests supplies the required `free-tests` check. GitHub holds the pull_request runs of a token-opened PR as "action required" until someone approves them. A file that fails while recording turns its @@ -1557,6 +1557,13 @@ refresh to a maintainer. ### Windows free lane +Windows has minimal support ([support tiers](support-tiers.md)). The required +PR check is `windows-smoke.yml`: one `windows-latest` job runs `./setup`, starts +the core skills, drives `/browse` on gstack's own browser, and runs the short +test list in its last step, where each file's reason sits beside it. +`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast. + +The curated suite below runs nightly on main and on dispatch, never on a PR. `windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6`), runs one strict serial shard per `windows-latest` job (`bun run test:windows --ci-run`), and verifies every shard receipt on Linux diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 99549bf1d..91c10f4aa 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -92,7 +92,7 @@ hard-code: "changelog": "CHANGELOG.md", "history": "squash-ok", "stamp_sensitive_gates": ["tests"], - "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, + "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, "min_gstack_version": "1.91.340.0" } ``` @@ -210,7 +210,7 @@ READY PR as conflicting each time main stamps; the train merges the CHANGELOG by section, so the lane never merges main for it. The conflict does mean GitHub has no merge ref for the PR, so no `pull_request` CI runs on the head and `ready` prints `ci: none at ` with a `ci-hint:` line. The lane then -runs `ready … --dispatch-ci free-tests.yml,windows-free-tests.yml [--timeout +runs `ready … --dispatch-ci free-tests.yml,windows-smoke.yml [--timeout ]` (under a train policy): it dispatches each named workflow on the branch (`gh workflow run --ref `, skipped when a run at this head already concluded), waits for the runs (`CI_DISPATCH: --ref run diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts index fe561b3c9..a4ce4e5a6 100644 --- a/lib/ship-queue-train-cli.ts +++ b/lib/ship-queue-train-cli.ts @@ -174,7 +174,7 @@ export async function readyMain(ctx: QueueContext, args: string[], base: { pr: s if (args.includes('--broad') && !screen) return usage('--broad selects the screen policy; it needs --screen'); const dispatchCi = flag(args, '--dispatch-ci'); const workflows = dispatchCi === undefined ? [] : dispatchCi.split(',').map(s => s.trim()).filter(Boolean); - if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-free-tests.yml)'); + if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-smoke.yml)'); const timeoutSec = positive(flag(args, '--timeout'), 2700); if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds'); const fixer = flag(args, '--fixer'); diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index ae3925ece..09471df27 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -175,7 +175,7 @@ function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean }, if (mergeable !== 'CONFLICTING') return null; const ref = branch && /^[\w.][\w./-]*$/.test(branch) ? `--ref ${branch}` : '--ref '; if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`; - if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`; + if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`; if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`; return null; } diff --git a/scripts/test-health-report.ts b/scripts/test-health-report.ts index 8d9e55168..7d89e60c9 100644 --- a/scripts/test-health-report.ts +++ b/scripts/test-health-report.ts @@ -292,13 +292,13 @@ export function collectHealth(inputs: HealthInputs): HealthReport { detail: window.files.slice(0, 15).map(file => `${file.file}: ${file.flakyRuns}/${window.runs} runs (${pct(file.rate)})${file.failing ? ' FAIL > 5%' : ''}`) }; })); - metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-free-tests.yml (its workflow_dispatch duration recordings excluded)', () => { + metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-smoke.yml, the required Windows check (its workflow_dispatch runs excluded)', () => { const walls = (workflow: string, filter: { branch?: string; event?: string }) => listWorkflowRuns(client, repo, workflow, { since, ...filter }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!); const free = walls('free-tests.yml', { branch: 'main' }); - const windows = walls('windows-free-tests.yml', { event: 'pull_request' }); + const windows = walls('windows-smoke.yml', { event: 'pull_request' }); const windowsValue = windows.length ? `${minutes(percentile(windows, 0.5))} (${windows.length} runs)` : 'no successful PR runs in window'; - return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-free-tests (successful PR runs) ${windowsValue}` }; + return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-smoke (successful PR runs) ${windowsValue}` }; })); metrics.push(metric('free-serial', 'Free-suite serial time', 'sum of scripts/free-test-durations.json (the committed seed)', () => { diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index 911e393d0..25e2d88dd 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -294,6 +294,7 @@ export const FREE_ONLY_PR_FILES = [ '.github/workflows/windows-free-tests.yml', '.github/workflows/windows-pregate-e2e.yml', '.github/workflows/windows-setup-e2e.yml', + '.github/workflows/windows-smoke.yml', ] as const; /** diff --git a/test/free-durations-record-workflow.test.ts b/test/free-durations-record-workflow.test.ts index 9bd99d135..5ec32eca0 100644 --- a/test/free-durations-record-workflow.test.ts +++ b/test/free-durations-record-workflow.test.ts @@ -82,7 +82,7 @@ test('the propose step touches only the two seeds, never the allowlist, on one b expect(script).not.toContain('free-test-seed-allowlist.json'); expect(script).toContain('branch=bot/free-duration-seeds'); expect(script).toContain('bun test test/free-seed-ratchet.test.ts'); - for (const workflow of ['free-tests.yml', 'windows-free-tests.yml', 'quality-gate.yml']) { + for (const workflow of ['free-tests.yml', 'windows-smoke.yml', 'quality-gate.yml']) { expect(script).toContain(`gh workflow run ${workflow} --repo "$GITHUB_REPOSITORY" --ref "$branch"`); expect(load(workflow).on).toHaveProperty('workflow_dispatch'); } diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index ac411ffc1..73d5fa2bb 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -561,10 +561,10 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts, const { r, at } = readyWith('CONFLICTING', { check_runs: [] }); expect(r.status).toBe(EXIT.refused); expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`); - expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`); + expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-smoke.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`); for (const name of ['x;$(curl evil)', '--field']) { const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: name }); - expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref && gh workflow run windows-free-tests.yml --ref '); + expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref && gh workflow run windows-smoke.yml --ref '); } const fork = readyWith('CONFLICTING', { check_runs: [] }, { isCrossRepository: true }); expect(line(fork.r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${fork.at}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`); diff --git a/test/test-health-report.test.ts b/test/test-health-report.test.ts index 0d654dba8..41f7d2736 100644 --- a/test/test-health-report.test.ts +++ b/test/test-health-report.test.ts @@ -189,7 +189,7 @@ describe('collectHealth over recorded fixture data', () => { [/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: [run(60), run(61, { conclusion: 'failure' })] })], [/runs\/60\/artifacts/, () => ({ artifacts: [{ id: 61, name: 'flake-ledger-2', size_in_bytes: 10 }] })], [/runs\/61\/artifacts/, () => ({ artifacts: [] })], - [/workflows\/windows-free-tests\.yml\/runs/, () => ({ workflow_runs: windowsRuns })], + [/workflows\/windows-smoke\.yml\/runs/, () => ({ workflow_runs: windowsRuns })], ], { 11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback', e2e: ['gate-a', 'gate-b'], judges: ['judge-a'], shadow: { e2e: ['gate-a', 'gate-b'], wouldDrop: [], wouldAdd: [], provenance: [], predictedSavingUsd: 0, unpricedCases: 0 } } }) }, @@ -218,7 +218,7 @@ describe('collectHealth over recorded fixture data', () => { expect(value('census-redispatch').value).toBe('0 of 1 census(es) re-dispatched; longest paid slice job 20.0 min'); expect(value('quarantine-evidence').value).toContain('insufficient evidence (not the same as healthy)'); expect(value('free-flaky-pass-rate').value).toStartWith('50.0% (1/2 main runs'); - expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 0.0 min (1 runs)'); + expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 0.0 min (1 runs)'); expect(value('free-serial').value).toBe('3 s over 3 files'); for (const entry of report.metrics.filter(entry => entry.id !== 'net-complexity')) { expect(entry.rule.length).toBeGreaterThan(10); @@ -287,10 +287,10 @@ describe('collectHealth over recorded fixture data', () => { const pr = (id: number, minutes: number, extra: Record = {}) => run(id, { event: 'pull_request', head_branch: `pr-${id}`, run_started_at: day(1), updated_at: new Date(Date.parse(day(1)) + minutes * 60_000).toISOString(), ...extra }); expect(wall([pr(70, 6), pr(71, 7), pr(74, 8), pr(72, 30, { conclusion: 'failure' }), run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value) - .toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 7.0 min (3 runs)'); + .toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 7.0 min (3 runs)'); expect(wall([run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value) - .toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) no successful PR runs in window'); - expect(wall([]).rule).toContain('workflow_dispatch duration recordings excluded'); + .toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) no successful PR runs in window'); + expect(wall([]).rule).toContain('workflow_dispatch runs excluded'); }); test('a rate-limited API degrades every remote metric to a named unavailable reason, never 0, and keeps the issue open', () => { diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index 549803548..3020d8584 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -14,6 +14,8 @@ const root = path.resolve(import.meta.dir, '..'); const load = (file: string) => Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows', file), 'utf8')) as any; const windows = load('windows-free-tests.yml'); const native = load('native-qualification.yml'); +const smoke = load('windows-smoke.yml'); +const smokeText = readFileSync(path.join(root, '.github/workflows/windows-smoke.yml'), 'utf8'); /** * Ratchet: the Windows-safe subset may not silently shrink. If you deleted or @@ -22,9 +24,9 @@ const native = load('native-qualification.yml'); */ const WINDOWS_CURATED_FLOOR = 795; -test('the Windows lane plans, runs one strict shard per job and verifies every result', () => { - expect(Object.keys(windows.on).sort()).toEqual(['pull_request', 'push', 'workflow_call', 'workflow_dispatch']); - expect(windows.on.push).toEqual({ branches: ['main'] }); +test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => { + expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']); + expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]); expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations']); expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']); const plan = windows.jobs['windows-plan']; @@ -35,6 +37,7 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r const shard = windows.jobs['windows-free-shard']; expect(shard['runs-on']).toBe('windows-latest'); expect(shard.needs).toBe('windows-plan'); + expect(shard.if).toBe('${{ !inputs.record_durations }}'); expect(shard.strategy).toEqual({ 'fail-fast': false, matrix: '${{ fromJSON(needs.windows-plan.outputs.matrix) }}' }); const run = shard.steps.find((step: any) => step.name === 'Run Windows-safe shard'); expect(run.run).toStartWith('bun run test:windows --ci-run "$RUNNER_TEMP/windows-plan.json" --shard ${{ matrix.shard }} --result '); @@ -77,6 +80,50 @@ test('a record_durations dispatch times each Windows-safe file alone and uploads expect(job.steps.at(-1).if).toBe("always() && steps.seed.outcome == 'success'"); }); +test('windows-smoke is the PR gate: every pull request and main push, one windows-latest job', () => { + expect(Object.keys(smoke.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch']); + expect(smoke.on.pull_request).toEqual({ branches: ['main'] }); + expect(smoke.on.push).toEqual({ branches: ['main'] }); + expect(smoke.permissions).toEqual({ contents: 'read' }); + expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']); + const job = smoke.jobs['windows-smoke']; + expect(job['runs-on']).toBe('windows-latest'); + expect(job.if).toBeUndefined(); + const step = (name: string) => job.steps.find((s: any) => s.name === name); + expect(step('./setup installs into an isolated HOME').run).toContain('./setup --host claude'); + const start = step('The core skills start').run; + expect(start).toContain('for skill in ship review qa-only investigate browse; do'); + expect(start).toContain("grep -q '^SKILL_START_PROTO: 1'"); + expect(step("/browse on gstack's own browser runs a piped chain flow from an unrelated directory").run).toContain('| "$BROWSE" chain'); + const tests = step('Fastest Windows-safe free tests for install and the core skills'); + expect(tests.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"'); + expect(tests.env.GSTACK_FREE_RETRY_FLAKY).toBe('1'); +}); + +test('every windows-smoke test is Windows-safe, seeded fast, and says why it is there', () => { + const run: string = smoke.jobs['windows-smoke'].steps.find((s: any) => s.name === 'Fastest Windows-safe free tests for install and the core skills').run; + const files = run.slice(run.indexOf("<<'LIST'\n") + 9, run.indexOf('\nLIST')).split('\n'); + expect(files.length).toBeGreaterThan(0); + expect(new Set(files).size).toBe(files.length); + const { safe } = curateWindowsSafe(collectFreeTestFiles(root), root); + expect(files.filter(file => !safe.includes(file))).toEqual([]); + const seed = JSON.parse(readFileSync(path.join(root, 'scripts/free-test-durations-windows.json'), 'utf8')).durations as Record; + expect(files.filter(file => !(seed[file] <= 5_000))).toEqual([]); + expect(files.reduce((sum, file) => sum + seed[file]!, 0)).toBeLessThan(60_000); + for (const file of files) expect(smokeText).toMatch(new RegExp('# ' + path.basename(file, '.test.ts').replace(/[.-]/g, '\\$&') + ' \\([0-9.]+\\): \\S')); +}); + +test('no Windows job restores or saves the bun install cache', () => { + // GSTA-203 PR 1: a cold install (16-17 s) beat restoring the ~450 MB + // windows-bun entry (33-42 s), and PR merge-ref saves filled the 10 GB quota. + for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml']) { + for (const [name, job] of Object.entries(load(file).jobs)) { + const caches = (job.steps ?? []).filter((step: any) => step.uses?.startsWith('actions/cache')); + expect({ file, job: name, caches }).toEqual({ file, job: name, caches: [] }); + } + } +}); + test('Windows shards use the pinned Node runtime and retain complete logs on every run', () => { const steps = windows.jobs['windows-free-shard'].steps; const node = steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));