From 8d796ac3d2172c73be48a601227afc8033a36173 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 01:59:24 +0000 Subject: [PATCH] ci: run Windows checks by path (ship-90-90 plan D, GST-1135) Windows Setup E2E skips a version-only package.json change; a new Windows Scoped Tests workflow runs a PR's changed Windows-safe tests when it touches path, file-name or process code. windows-smoke is unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/windows-scoped-tests.yml | 201 ++++++++++++++ .github/workflows/windows-setup-e2e.yml | 109 ++++++++ changelog.d/gsta258-l3-windows-by-path.md | 8 + docs/support-tiers.md | 1 + test/windows-native-workflows.test.ts | 2 +- test/windows-path-scope-workflow.test.ts | 298 +++++++++++++++++++++ 6 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/windows-scoped-tests.yml create mode 100644 changelog.d/gsta258-l3-windows-by-path.md create mode 100644 test/windows-path-scope-workflow.test.ts diff --git a/.github/workflows/windows-scoped-tests.yml b/.github/workflows/windows-scoped-tests.yml new file mode 100644 index 000000000..1d5816b00 --- /dev/null +++ b/.github/workflows/windows-scoped-tests.yml @@ -0,0 +1,201 @@ +name: Windows Scoped Tests + +# The path-scoped Windows free shard (ship-90-90 plan D). windows-smoke.yml is +# still the required Windows check on every pull request, and the full +# curated suite (windows-free-tests.yml) still runs nightly on main. This +# workflow sits between them: when a pull request touches path, file-name or +# process code, one windows-latest job runs the test files the pull request +# changed that are in the curated Windows-safe subset. +# +# "Path, file-name or process code" (the windows-scope filter step): +# - file names: anything under a bin/ directory (shell entry points and +# skill hooks such as freeze/bin/check-freeze.sh, #3758), shell and +# PowerShell scripts, hosts/*/hooks, setup, bun.lock, the Windows +# curation (scripts/test-free-shards.ts, scripts/lib/windows-curation.ts, +# the Windows duration seed, windows-free-tests.yml), and code files whose +# names say path, spawn, exec, process, shell, launch, win32, windows, +# tmp, temp, symlink, state-root, slug, claude-bin, bun-isolation or +# script-invocation; +# - added or removed code lines that spawn processes or spell paths: +# child_process, spawn/exec calls, Bun.spawn, Bun.$, path.win32 and the +# other separator-sensitive path calls, process.platform/execPath, win32, +# tmpdir(), /dev/null, /tmp/, symlink, realpath, chmod, cygpath, +# USERPROFILE, homedir(), fileURLToPath and .exe. #3829's Windows break was +# one such line in a test it added: spawnSync(process.execPath, [..., +# '--config=/dev/null']), which native Windows Bun cannot open. +# test/windows-path-scope-workflow.test.ts proves the filter catches #3758 +# and #3829 and passes over a docs-only and a pure-logic change. +# +# The windows-scoped-tests job is the check to read: it passes when nothing +# is in scope (no Windows job ran) and otherwise requires the Windows job, so +# a skipped run still reports success if this ever becomes a required check. +# gstack-pregate reads a job keyed on a dispatch input as manual, never a PR +# obligation: this check runs on the pull request itself. A workflow_dispatch +# run with the files input reruns chosen Windows-safe files on a branch. + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + files: + description: Space-separated Windows-safe free test files to run (from bun run test:windows --list) + type: string + required: true + +concurrency: + group: windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +# Test-only lane — no token writes. +permissions: + contents: read + +jobs: + windows-scope: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + files: ${{ steps.select.outputs.files }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 2 + persist-credentials: false + - id: filter + name: Does the diff touch path, file-name or process code? + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + git diff --name-only --no-renames HEAD^1 HEAD > "$RUNNER_TEMP/changed-files" + code_re='\.(ts|tsx|js|mjs|cjs|sh|bash|ps1|cmd|bat)$|(^|/)bin/[^/.]+$|(^|/)setup$' + path_re='(^|/)bin/|\.(sh|bash|ps1|cmd|bat)$|(^|/)hosts/[^/]+/hooks/|(^|/)setup$|^bun\.lock$|^scripts/test-free-shards\.ts$|^scripts/lib/windows-curation\.ts$|^scripts/free-test-durations-windows\.json$|^\.github/workflows/windows-free-tests\.yml$' + name_re='(path|spawn|exec|process|shell|launch|win32|windows|tmp|temp|symlink|state-root|slug|claude-bin|bun-isolation|script-invocation)[^/]*$' + content_re='child_process|\b(spawn|spawnSync|execSync|execFile|execFileSync)[[:space:]]*\(|Bun\.(spawn|spawnSync|\$)|\bpath\.(win32|posix|sep|delimiter|relative|isAbsolute|normalize)\b|process\.(platform|execPath)|\bwin32\b|tmpdir\(|/dev/null|/tmp/|symlink|realpath|chmod|cygpath|USERPROFILE|homedir\(|fileURLToPath|\.exe\b' + { + grep -E "$path_re" "$RUNNER_TEMP/changed-files" | sed 's/^/path: /' || true + grep -E "$code_re" "$RUNNER_TEMP/changed-files" | grep -E "$name_re" | sed 's/^/name: /' || true + { grep -E "$code_re" "$RUNNER_TEMP/changed-files" || true; } | while IFS= read -r file; do + git diff -U0 --no-renames HEAD^1 HEAD -- "$file" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' | grep -m1 -E "$content_re" | sed "s|^|content: $file: |" || true + done + } > "$RUNNER_TEMP/windows-scope" + if [ -s "$RUNNER_TEMP/windows-scope" ]; then + echo 'windows=true' >> "$GITHUB_OUTPUT" + { echo 'This pull request touches path, file-name or process code:'; echo '~~~'; head -n 40 "$RUNNER_TEMP/windows-scope"; echo '~~~'; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'windows=false' >> "$GITHUB_OUTPUT" + echo 'This pull request touches no path, file-name or process code, so no Windows job runs. windows-smoke still runs on every pull request.' >> "$GITHUB_STEP_SUMMARY" + fi + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.filter.outputs.windows == 'true' + with: + bun-version: 1.4.2 + - id: select + name: Pick the changed test files in the curated Windows-safe subset + if: steps.filter.outputs.windows == 'true' + run: | + set -euo pipefail + GITHUB_STEP_SUMMARY=/dev/null bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/curated.json" --shards 1 > /dev/null + jq -r '.shards[].files[]' "$RUNNER_TEMP/curated.json" | LC_ALL=C sort -u > "$RUNNER_TEMP/curated" + LC_ALL=C sort -u "$RUNNER_TEMP/changed-files" | LC_ALL=C comm -12 - "$RUNNER_TEMP/curated" > "$RUNNER_TEMP/selected" + if [ ! -s "$RUNNER_TEMP/selected" ]; then + echo 'No changed test file is in the curated Windows-safe subset, so no Windows job runs.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { echo 'files<> "$GITHUB_OUTPUT" + { echo "The Windows job runs $(wc -l < "$RUNNER_TEMP/selected" | tr -d ' ') changed Windows-safe test file(s):"; sed 's/^/- /' "$RUNNER_TEMP/selected"; } >> "$GITHUB_STEP_SUMMARY" + + windows-scoped-shard: + needs: windows-scope + if: ${{ (github.event_name == 'workflow_dispatch' && inputs.files != '') || needs.windows-scope.outputs.files != '' }} + runs-on: windows-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 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.18.0 + + # The same inputs as a windows-free-tests.yml shard, with no bun install + # cache (a cold install beats restoring it on windows-latest). + - name: Configure git identity (required by tests that init temp repos) + run: | + git config --global user.email "windows-ci@gstack.test" + git config --global user.name "Windows CI" + git config --global init.defaultBranch main + shell: bash + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build server-node.mjs (required by Windows browse path) + run: bash browse/scripts/build-node-server.sh + shell: bash + + - name: Generate host SKILL.md outputs (.agents, .factory) + run: bun run gen:skill-docs --host all + shell: bash + + - name: Install Chromium for the Node worker smoke + run: bunx playwright install chromium + + - name: Run the scoped Windows-safe test files + env: + FILES: ${{ inputs.files || needs.windows-scope.outputs.files }} + GSTACK_FREE_RETRY_FLAKY: '1' + GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} + run: | + set -euo pipefail + printf '%s\n' "$FILES" | tr -d '\r' | tr ' ' '\n' | sed '/^$/d' > "$RUNNER_TEMP/windows-scoped-files.txt" + bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt" + shell: bash + + - name: Upload full test logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: windows-scoped-test-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-scoped + path: ${{ runner.temp }}/flake-ledger.jsonl + if-no-files-found: ignore + retention-days: 90 + + windows-scoped-tests: + needs: [windows-scope, windows-scoped-shard] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Pass when nothing is in scope, else require the Windows job + env: + SCOPE_RESULT: ${{ needs.windows-scope.result }} + SHARD_RESULT: ${{ needs.windows-scoped-shard.result }} + FILES: ${{ inputs.files || needs.windows-scope.outputs.files }} + run: | + set -eu + test "$SCOPE_RESULT" = success + if [ -z "$FILES" ]; then + echo 'Nothing in scope (no changed Windows-safe test file in a pull request that touches path, file-name or process code); passing without a Windows run.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + test "$SHARD_RESULT" = success diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 2ca74e7b4..21307f972 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -32,6 +32,13 @@ name: Windows Setup E2E # Separate from windows-free-tests.yml because that one runs a curated # unit-test subset; this one exercises the install path itself. # +# Runs by path (ship-90-90 plan D): the paths below are the install files +# this workflow runs or ./setup sources or runs. The windows-scope job then +# drops a pull request whose only install change is package.json's version +# line, which is what the merge-train stamp commit puts on every PR head. +# windows-scope's list must match the paths list (pinned by +# test/windows-path-scope-workflow.test.ts). +# # Runner: GitHub-hosted free windows-latest, two parallel jobs. ~10-25 min. on: @@ -39,6 +46,7 @@ on: branches: [main] paths: - 'package.json' + - 'bun.lock' - 'scripts/build.sh' - 'scripts/build-cso.sh' - 'scripts/build-cso-windows.ps1' @@ -64,6 +72,18 @@ on: - 'bin/gstack-claude-root.sh' - 'bin/gstack-browser-ensure' - 'bin/gstack-path-spelling.sh' + - 'bin/gstack-config' + - 'bin/gstack-patch-names' + - 'bin/gstack-state-root.sh' + - 'bin/gstack-skill-name.sh' + - 'bin/gstack-install-registry.sh' + - 'bin/gstack-render-claude.sh' + - 'bin/gstack-host-renders.sh' + - 'bin/gstack-migrate-claude-code' + - 'bin/gstack-settings-hook' + - 'bin/gstack-relink' + - 'bin/gstack-harness' + - 'scripts/preflight-codex-overlap.ts' - 'lib/path-spelling.ts' - 'hosts/claude/hooks/**' - 'gstack-upgrade/**' @@ -81,7 +101,94 @@ permissions: contents: read jobs: + windows-scope: + # The paths filter above starts this workflow; this job decides whether + # the Windows jobs run. A workflow_dispatch run always runs them. + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + install: ${{ steps.scope.outputs.install }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 2 + persist-credentials: false + - id: scope + name: Run unless the only install change is package.json's version line + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'install=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + pkg_version_only=true + if git diff -U0 --no-renames HEAD^1 HEAD -- package.json | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' \ + | grep -vE '^[+-][[:space:]]*"version":[[:space:]]*"[^"]*",?[[:space:]]*$' > /dev/null; then + pkg_version_only=false + fi + git diff --name-only --no-renames HEAD^1 HEAD | while IFS= read -r file; do + if [ "$file" = package.json ] && [ "$pkg_version_only" = true ]; then continue; fi + while IFS= read -r glob; do + # shellcheck disable=SC2254 + case "$file" in ${glob//\*\*/*}) echo "$file"; break ;; esac + done <<'PATHS' + package.json + bun.lock + scripts/build.sh + scripts/build-cso.sh + scripts/build-cso-windows.ps1 + scripts/write-version-files.sh + setup + setup.ps1 + test/setup-ps1.test.ts + docs/troubleshooting.md + browse/src/cli.ts + browse/src/find-browse.ts + browse/src/server.ts + browse/src/meta-commands.ts + browse/scripts/build-node-server.sh + design/src/cli.ts + make-pdf/src/cli.ts + bin/gstack-global-discover.ts + bin/gstack-paths + bin/gstack-doctor + bin/gstack-launch-probe.sh + bin/gstack-hook-check + bin/gstack-session-update + bin/gstack-ff-update + bin/gstack-claude-root.sh + bin/gstack-browser-ensure + bin/gstack-path-spelling.sh + bin/gstack-config + bin/gstack-patch-names + bin/gstack-state-root.sh + bin/gstack-skill-name.sh + bin/gstack-install-registry.sh + bin/gstack-render-claude.sh + bin/gstack-host-renders.sh + bin/gstack-migrate-claude-code + bin/gstack-settings-hook + bin/gstack-relink + bin/gstack-harness + scripts/preflight-codex-overlap.ts + lib/path-spelling.ts + hosts/claude/hooks/** + gstack-upgrade/** + .github/workflows/windows-setup-e2e.yml + PATHS + done > "$RUNNER_TEMP/install-files" + if [ -s "$RUNNER_TEMP/install-files" ]; then + echo 'install=true' >> "$GITHUB_OUTPUT" + { echo 'Install files changed, so the Windows jobs run:'; sed 's/^/- /' "$RUNNER_TEMP/install-files"; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'install=false' >> "$GITHUB_OUTPUT" + echo "The only install change is package.json's version line (the merge-train stamp), so the Windows jobs do not run." >> "$GITHUB_STEP_SUMMARY" + fi + windows-setup: + needs: windows-scope + if: needs.windows-scope.outputs.install == 'true' runs-on: windows-latest timeout-minutes: 35 @@ -377,6 +484,8 @@ jobs: # docs/troubleshooting.md, against the runner's real ~/.claude install # path ($HOME in PowerShell). GSTACK_E2E_POWERSHELL and # GSTACK_E2E_UPDATER_PATH_PREFIX exist only to run these steps off Windows. + needs: windows-scope + if: needs.windows-scope.outputs.install == 'true' runs-on: windows-latest timeout-minutes: 50 env: diff --git a/changelog.d/gsta258-l3-windows-by-path.md b/changelog.d/gsta258-l3-windows-by-path.md new file mode 100644 index 000000000..ff2b2e838 --- /dev/null +++ b/changelog.d/gsta258-l3-windows-by-path.md @@ -0,0 +1,8 @@ +**Windows checks now run by path.** Windows Setup E2E, the 10-to-25-minute install test, no longer starts its Windows jobs on a pull request whose only install change is `package.json`'s version line, which the merge-train stamp puts on every pull request before it merges. Of the 61 pull requests in its last 100 runs, 37 changed no install file besides `package.json`. Its path list also gains `bun.lock` and the `bin/` scripts that the test or `./setup` runs. A new `Windows Scoped Tests` workflow runs the Windows-safe test files a pull request changed, on one Windows job, when the pull request touches path, file-name or process code, such as a hook script or a new `spawnSync` call. On the last 120 commits to main it would have run on 50, with a median of one test file. It catches both Windows breaks the plan names: /freeze's path check (#3758) and a test that started Bun with `--config=/dev/null` (#3829). `windows-smoke` still runs on every pull request, and the full Windows suite still runs nightly. + +### CI +- `.github/workflows/windows-setup-e2e.yml`: a Linux `windows-scope` job runs first and skips both Windows jobs when the only install change is `package.json`'s `"version"` line. Paths add `bun.lock`, `bin/gstack-config`, `bin/gstack-patch-names` and the scripts `./setup` sources or runs. +- `.github/workflows/windows-scoped-tests.yml` (new): a Linux scope job reads the diff's file names and changed code lines, picks the changed files in the curated Windows-safe subset, and a `windows-latest` job runs them. The `windows-scoped-tests` check passes when nothing is in scope, so a skipped run still reports success. A `workflow_dispatch` with a `files` input reruns chosen Windows-safe files on a branch. + +### Tests +- `test/windows-path-scope-workflow.test.ts`: runs both scope steps in a two-commit git repository. The filter catches #3758's and #3829's changed files and passes over a docs-and-stamp change and a pure-logic change. The Setup E2E scope skips a version-only `package.json` bump and runs on a dependency change. `windows-smoke` stays unfiltered, and no job that can skip is a required check. diff --git a/docs/support-tiers.md b/docs/support-tiers.md index 05b01b4aa..33c1fb0aa 100644 --- a/docs/support-tiers.md +++ b/docs/support-tiers.md @@ -26,6 +26,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native - `windows-smoke` is the required Windows check on pull requests. - The six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs nightly on main, or when someone starts it by hand, and never blocks a pull request. - A red nightly run gets a fix only when it shows an install, startup or core-workflow break. +- Windows checks run by path (ship-90-90 plan D, approved 2026-11-03): `Windows Scoped Tests` runs a pull request's changed Windows-safe test files on one Windows job only when it touches path, file-name or process code, and `Windows Setup E2E` runs only when install files change (a version-only `package.json` change doesn't count). ## What doesn't change diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index a111dc605..ed41250e5 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -148,7 +148,7 @@ test('every windows-smoke test is Windows-safe, seeded fast, and says why it is 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 file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml', 'windows-scoped-tests.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: [] }); diff --git a/test/windows-path-scope-workflow.test.ts b/test/windows-path-scope-workflow.test.ts new file mode 100644 index 000000000..a990bc530 --- /dev/null +++ b/test/windows-path-scope-workflow.test.ts @@ -0,0 +1,298 @@ +/** + * Windows checks by path (ship-90-90 plan D, GST-1135). Pins three things: + * + * 1. windows-smoke.yml stays the unfiltered Windows check on every pull + * request. + * 2. windows-setup-e2e.yml starts only on install paths, and its + * windows-scope job keeps the two Windows jobs off a pull request whose + * only install change is package.json's version line (the merge-train + * stamp). + * 3. windows-scoped-tests.yml runs the changed Windows-safe test files when + * a pull request touches path, file-name or process code. Its filter must + * catch #3758 (/freeze's check-freeze.sh, a hook path) and #3829 (a test + * that spawned Bun with --config=/dev/null), and pass over a docs-only + * and a pure-logic change. Its aggregate passes when nothing is in scope, + * so a skipped run still reports success. + * + * The filter and scope steps run here as the runner would run them: bash in a + * two-commit git repository, with HEAD^1 as the pull request's base. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { parseWorkflow } from '../lib/pregate/workflows'; +import { collectFreeTestFiles, curateWindowsSafe } from '../scripts/test-free-shards'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const SCOPED_FILE = 'windows-scoped-tests.yml'; +const SETUP_FILE = 'windows-setup-e2e.yml'; +const scopedText = read(`.github/workflows/${SCOPED_FILE}`); +const setupText = read(`.github/workflows/${SETUP_FILE}`); +const scoped = Bun.YAML.parse(scopedText) as any; +const setupE2e = Bun.YAML.parse(setupText) as any; +const smoke = Bun.YAML.parse(read('.github/workflows/windows-smoke.yml')) as any; +const toShell = (p: string) => p.split(path.sep).join('/'); +const CAN_RUN = !!Bun.which('bash') && !!Bun.which('git'); + +const PKG = (version: string, deps = '"a": "1.0.0"') => `{\n "name": "gstack",\n "version": "${version}",\n "dependencies": {\n ${deps}\n }\n}\n`; + +function git(cwd: string, args: string[]): string { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} + +/** A repository with a base commit and one change on top: HEAD^1 plays the pull request's base, as on the merge ref. */ +function repo(base: Record, change: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-')); + const write = (files: Record) => { + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + }; + git(dir, ['init', '-q']); + write(base); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'base']); + write(change); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'change']); + return dir; +} + +/** GITHUB_OUTPUT lines, including name< { + const out: Record = {}; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const block = /^([\w-]+)<<(.+)$/.exec(lines[i]!); + if (block) { + const end = lines.indexOf(block[2]!, i + 1); + out[block[1]!] = lines.slice(i + 1, end).join('\n'); + i = end; + } else if (lines[i]!.includes('=')) { + const at = lines[i]!.indexOf('='); + out[lines[i]!.slice(0, at)] = lines[i]!.slice(at + 1); + } + } + return out; +} + +interface StepRun { status: number | null; stderr: string; outputs: Record; summary: string; temp: string } + +/** Run one workflow step's script under bash with a fresh GITHUB_OUTPUT and summary; temp is RUNNER_TEMP, kept for a later step. */ +function runStep(script: string, cwd: string, env: Record = {}, temp = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-temp-'))): StepRun { + const output = path.join(temp, 'github-output'); + const summary = path.join(temp, 'github-summary'); + fs.writeFileSync(output, ''); + fs.writeFileSync(summary, ''); + const r = spawnSync('bash', ['-c', script], { + cwd, encoding: 'utf8', timeout: 30_000, + env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? temp, GITHUB_EVENT_NAME: 'pull_request', RUNNER_TEMP: toShell(temp), GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(summary), ...env }, + }); + return { status: r.status, stderr: r.stderr, outputs: parseOutputs(fs.readFileSync(output, 'utf8')), summary: fs.readFileSync(summary, 'utf8'), temp }; +} + +const step = (job: any, id: string) => job.steps.find((s: any) => s.id === id); +const filterScript: string = step(scoped.jobs['windows-scope'], 'filter').run; +const selectScript: string = step(scoped.jobs['windows-scope'], 'select').run; +const setupScopeScript: string = step(setupE2e.jobs['windows-scope'], 'scope').run; + +// #3758 (v1.92.9.0, /freeze checks where an edit really lands): its changed files. Its Windows failure was in +// test/hook-scripts.test.ts, from check-freeze.sh's path handling (run 41243040943). +const PR_3758 = ['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'careful/bin/hook-extract.sh', 'freeze/SKILL.md', 'freeze/SKILL.md.tmpl', + 'freeze/bin/check-freeze.sh', 'package.json', 'test/hook-scripts.test.ts']; +// #3829 (v1.92.61.0, risk classes cover child_process): its changed files, none named for paths or processes. Its +// Windows failure (nightly run 41420490350) was test/review-risk-linear.test.ts, a file it added, spawning Bun with +// --config=/dev/null: EINVAL on native Windows. +const PR_3829 = ['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'docs/review-policy.md', 'lib/review-collect.ts', 'lib/review-depth.ts', + 'lib/review-risk.ts', 'lib/review-targeted.ts', 'package.json', 'test/fixtures/review-risk/gbrain-evals-289.json', 'test/helpers/free-fixtures-data.ts', + 'test/review-packets-removed.test.ts', 'test/review-risk-linear.test.ts', 'test/review-risk-routing.test.ts']; +const LINE_3829 = " const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });"; + +/** Every file of a pull request with a neutral one-line edit, package.json as a version bump, plus the given lines. */ +function prRepo(files: string[], lines: Record = {}) { + const base: Record = {}; + const change: Record = {}; + for (const file of files) { + base[file] = file === 'package.json' ? PKG('1.0.0') : 'unchanged\n'; + change[file] = file === 'package.json' ? PKG('1.0.1') : `unchanged\nedited\n${lines[file] ?? ''}\n`; + } + return repo(base, change); +} + +describe('windows-smoke stays on every pull request', () => { + test('no paths filter, no job condition, one job', () => { + expect(smoke.on.pull_request).toEqual({ branches: ['main'] }); + expect(smoke.on.push).toEqual({ branches: ['main'] }); + expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']); + expect(smoke.jobs['windows-smoke'].if).toBeUndefined(); + expect(smoke.jobs['windows-smoke'].needs).toBeUndefined(); + }); + + test('no Windows job that can skip is a required check, and the scoped aggregate always reports', () => { + const required = JSON.parse(read('.gstack/ship-policy.json')).changelog_fragments.required_checks as string[]; + expect(required).toContain('windows-smoke'); + for (const name of ['windows-scope', 'windows-setup', 'held-install-journey', 'windows-scoped-shard', 'Windows Setup E2E']) expect(required).not.toContain(name); + expect(scoped.jobs['windows-scoped-tests'].if).toBe('always()'); + }); +}); + +describe('Windows Setup E2E runs by install path', () => { + const paths: string[] = setupE2e.on.pull_request.paths; + + test('pull requests on install paths and dispatch only; every Windows job waits on windows-scope', () => { + expect(Object.keys(setupE2e.on).sort()).toEqual(['pull_request', 'workflow_dispatch']); + expect(setupE2e.on.pull_request.branches).toEqual(['main']); + for (const p of ['setup', 'setup.ps1', 'package.json', 'bun.lock', 'gstack-upgrade/**', 'bin/gstack-config', 'bin/gstack-patch-names', + 'bin/gstack-state-root.sh', 'bin/gstack-migrate-claude-code', 'hosts/claude/hooks/**', 'docs/troubleshooting.md', `.github/workflows/${SETUP_FILE}`]) expect(paths).toContain(p); + expect(new Set(paths).size).toBe(paths.length); + for (const p of paths.filter(p => !p.includes('*'))) expect([p, fs.existsSync(path.join(ROOT, p))]).toEqual([p, true]); + const scope = setupE2e.jobs['windows-scope']; + expect(scope['runs-on']).toBe('ubuntu-24.04'); + expect(scope.steps[0].with).toEqual({ 'fetch-depth': 2, 'persist-credentials': false }); + const windowsJobs = Object.keys(setupE2e.jobs).filter(name => name !== 'windows-scope'); + expect(windowsJobs).toEqual(['windows-setup', 'held-install-journey']); + for (const name of windowsJobs) { + expect([name, setupE2e.jobs[name]['runs-on'], setupE2e.jobs[name].needs, setupE2e.jobs[name].if]) + .toEqual([name, 'windows-latest', 'windows-scope', "needs.windows-scope.outputs.install == 'true'"]); + } + }); + + test("windows-scope's install list is the paths list", () => { + const list = setupScopeScript.slice(setupScopeScript.indexOf("<<'PATHS'\n") + 10, setupScopeScript.indexOf('\nPATHS\n')).split('\n'); + expect(list).toEqual(paths); + }); + + test('pregate still reads windows-setup as a Windows lane obligation (no condition keys on a dispatch input)', () => { + const lanes = parseWorkflow(SETUP_FILE, setupText, 'linux').filter(l => l.runsTests); + expect(lanes.map(l => [l.job, l.manual, l.tier])).toContainEqual(['windows-setup', false, 'platform']); + }); + + test.skipIf(!CAN_RUN)('a version-only package.json change starts no Windows job; any other install change does', () => { + const scope = (base: Record, change: Record) => { + const dir = repo(base, change); + try { + const r = runStep(setupScopeScript, dir); + expect(r.status, r.stderr).toBe(0); + return r.outputs.install; + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }; + const base = { 'package.json': PKG('1.0.0'), setup: '#!/bin/bash\n', 'lib/x.ts': 'export const x = 1;\n', 'hosts/claude/hooks/a/b.ts': 'a\n' }; + expect(scope(base, { 'package.json': PKG('1.0.1'), 'lib/x.ts': 'export const x = 2;\n' })).toBe('false'); + expect(scope(base, { 'package.json': PKG('1.0.1', '"a": "1.0.1"') })).toBe('true'); + expect(scope(base, { 'package.json': PKG('1.0.1'), setup: '#!/bin/bash\necho changed\n' })).toBe('true'); + expect(scope(base, { 'hosts/claude/hooks/a/b.ts': 'b\n' })).toBe('true'); + const dispatched = runStep(setupScopeScript, ROOT, { GITHUB_EVENT_NAME: 'workflow_dispatch' }); + expect([dispatched.status, dispatched.outputs.install]).toEqual([0, 'true']); + }); +}); + +describe('the path-scoped Windows shard on pull requests', () => { + test('every pull request runs the cheap scope job; a dispatch names its files; read-only and secretless', () => { + expect(Object.keys(scoped.on).sort()).toEqual(['pull_request', 'workflow_dispatch']); + expect(scoped.on.pull_request).toEqual({ branches: ['main'] }); + expect(scoped.on.workflow_dispatch.inputs.files).toMatchObject({ type: 'string', required: true }); + expect(scoped.permissions).toEqual({ contents: 'read' }); + expect(scopedText).not.toContain('secrets.'); + expect(scopedText).not.toContain('pull_request_target'); + expect(scoped.concurrency.group).toBe('windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }}'); + const scope = scoped.jobs['windows-scope']; + expect(scope['runs-on']).toBe('ubuntu-24.04'); + expect(scope.steps[0].with).toEqual({ 'fetch-depth': 2, 'persist-credentials': false }); + const shard = scoped.jobs['windows-scoped-shard']; + expect(shard['runs-on']).toBe('windows-latest'); + expect(shard.needs).toBe('windows-scope'); + expect(shard.steps[0].with).toEqual({ 'persist-credentials': false }); + const run = shard.steps.find((s: any) => s.name === 'Run the scoped Windows-safe test files'); + expect(run.env.FILES).toBe('${{ inputs.files || needs.windows-scope.outputs.files }}'); + expect(run.env.GSTACK_FREE_RETRY_FLAKY).toBe('1'); + expect(run.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt"'); + expect(scoped.jobs['windows-scoped-tests'].needs).toEqual(['windows-scope', 'windows-scoped-shard']); + }); + + test('pregate reads the scoped shard as manual, so it adds no remote-clear obligation', () => { + const lanes = parseWorkflow(SCOPED_FILE, scopedText, 'linux').filter(l => l.runsTests); + expect(lanes.map(l => [l.job, l.manual])).toEqual([['windows-scoped-shard', true]]); + }); + + test("#3758's and #3829's failing test files are in the curated Windows-safe subset", () => { + const { safe } = curateWindowsSafe(collectFreeTestFiles(ROOT), ROOT); + expect(safe).toContain('test/hook-scripts.test.ts'); + expect(safe).toContain('test/review-risk-linear.test.ts'); + }); + + const CAN_SELECT = CAN_RUN && !!Bun.which('jq'); + /** Filter, then select with a stub bun whose curated plan holds the given files. */ + function scopeJob(dir: string, curated: string[]) { + const filter = runStep(filterScript, dir); + expect(filter.status, filter.stderr).toBe(0); + const scope = fs.readFileSync(path.join(filter.temp, 'windows-scope'), 'utf8'); + if (filter.outputs.windows !== 'true') return { windows: filter.outputs.windows, scope, files: undefined as string | undefined }; + const bin = path.join(filter.temp, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = --ci-plan ] && printf \'%s\' "$PLAN_JSON" > "$2"; shift; done\necho \'{"shard":[1]}\'\n', { mode: 0o755 }); + const plan = JSON.stringify({ version: 1, revision: 'r', shards: [{ shard: 1, predictedMs: 1, files: curated }], id: 'x' }); + const select = runStep(selectScript, dir, { PATH: `${toShell(bin)}:${process.env.PATH}`, PLAN_JSON: plan }, filter.temp); + expect(select.status, select.stderr).toBe(0); + return { windows: filter.outputs.windows, scope, files: select.outputs.files }; + } + const CURATED = ['test/hook-scripts.test.ts', 'test/review-risk-linear.test.ts', 'test/review-risk-routing.test.ts']; + + test.skipIf(!CAN_SELECT)('#3758: the hook paths put it in scope and the Windows job runs test/hook-scripts.test.ts', () => { + const dir = prRepo(PR_3758); + try { + const job = scopeJob(dir, CURATED); + expect(job.windows).toBe('true'); + expect(job.scope).toContain('path: careful/bin/hook-extract.sh'); + expect(job.scope).toContain('path: freeze/bin/check-freeze.sh'); + expect(job.files).toBe('test/hook-scripts.test.ts'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test.skipIf(!CAN_SELECT)('#3829: no path names it; the spawn line it added puts it in scope and the job runs its changed tests', () => { + const dir = prRepo(PR_3829, { 'test/review-risk-linear.test.ts': LINE_3829 }); + try { + const job = scopeJob(dir, CURATED); + expect(job.windows).toBe('true'); + expect(job.scope.trim().split('\n')).toEqual([`content: test/review-risk-linear.test.ts: +${LINE_3829}`]); + expect(job.files).toBe('test/review-risk-linear.test.ts\ntest/review-risk-routing.test.ts'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test.skipIf(!CAN_SELECT)('negative controls: a docs-and-stamp change and a pure-logic change start no Windows job', () => { + for (const [files, lines] of [ + [['CHANGELOG.md', 'VERSION', 'package.json', 'docs/support-tiers.md', 'changelog.d/x.md'], {}], + [['lib/review-depth.ts', 'test/review-depth.test.ts'], { 'lib/review-depth.ts': 'export const LIMIT = 3;', 'test/review-depth.test.ts': ' expect(LIMIT).toBe(3);' }], + ] as Array<[string[], Record]>) { + const dir = prRepo(files, lines); + try { + const job = scopeJob(dir, [...CURATED, 'test/review-depth.test.ts']); + expect([files, job.windows, job.scope]).toEqual([files, 'false', '']); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } + }); + + test.skipIf(!CAN_SELECT)('in scope but no changed Windows-safe test file: no files, so no Windows job', () => { + const dir = prRepo(['bin/gstack-example', 'test/posix-only.test.ts']); + try { + const job = scopeJob(dir, CURATED); + expect([job.windows, job.files]).toEqual(['true', undefined]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test.skipIf(!Bun.which('bash'))('the aggregate passes when nothing is in scope and otherwise requires the Windows job', () => { + const aggregate = scoped.jobs['windows-scoped-tests'].steps[0]; + expect(aggregate.env).toEqual({ SCOPE_RESULT: '${{ needs.windows-scope.result }}', SHARD_RESULT: '${{ needs.windows-scoped-shard.result }}', FILES: '${{ inputs.files || needs.windows-scope.outputs.files }}' }); + const status = (env: Record) => runStep(aggregate.run, ROOT, env).status; + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'skipped', FILES: '' })).toBe(0); + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'success', FILES: 'test/a.test.ts' })).toBe(0); + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'failure', FILES: 'test/a.test.ts' })).not.toBe(0); + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'cancelled', FILES: 'test/a.test.ts' })).not.toBe(0); + expect(status({ SCOPE_RESULT: 'failure', SHARD_RESULT: 'skipped', FILES: '' })).not.toBe(0); + }); +});