v1.94.10.0 ci: run Windows checks by path (ship-90-90 plan D, GST-1135) (#3904)

* 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>

* fix: pre-landing review fixes

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

* fix: pre-landing review fixes (round 2)

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

* docs: describe path-scoped Windows checks

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-03 08:43:28 +00:00
1 parent f33fd30b02
commit f6d6215f4e
14 files changed
+662 -9

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
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 reports success. It is not one of the required checks, but
# gstack-ship-queue ready and land refuse a head with any failed check, so a
# red Windows job blocks the pull request it runs on: in a pull request that
# touches path, file-name or process code, a changed curated test file has to
# pass on Windows. A change to path or process code with no changed curated
# test selects nothing; the nightly suite covers it.
# 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" | awk -v f="$file" '{ print "content: " f ": " $0 }' || 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<<GSTACK_SCOPED_FILES'; cat "$RUNNER_TEMP/selected"; echo 'GSTACK_SCOPED_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
+109
View File
@@ -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 '^[+-] "version": "[^"]*",?$' > /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:
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.94.10.0] - 2026-11-03
**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 (as of 2026-11-03), 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 (through 923fc0a) 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). A red run blocks its pull request like any failed check, so this release also fixes #3829's test, which has failed every nightly Windows run since it landed. `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/review-risk-linear.test.ts`: the rules-hash case starts Bun with `bunIsolationArgs()` (`--config=NUL` on native Windows) instead of `--config=/dev/null`, which native Windows Bun cannot open (nightly Windows Free Tests run 41420490350, EINVAL).
- `scripts/test-pr-profile.ts`: the new workflow is listed as free-lane only beside the other Windows workflows (test/free-fixtures.test.ts requires every workflow to be classified).
- `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.
## [1.94.9.0] - 2026-11-03
**A pull request can clear its remote gate obligation from a run with named flaky failures, once the owner grants it (GST-1096).** A timing flake in a test file the pull request never touched used to keep the pregate red with no way through but another paid run. `gstack-pregate clear --remote <lane> --run <id|url> --flaky-exception "<file>::<case>" --pr <n> --grant <approval id>` now accepts such a run when every failure is named from the run's own shard results and logs, at most three cases, each file outside the pull request's diff and its import closure, none paid and none a security scan test, and a gstack-approvals row in the owner's words binds the repository, PR, head, tree, lane, run attempt and cases. It covers the pregate only, and a red required CI check still needs its own green run.
+2 -1
View File
@@ -193,7 +193,8 @@ the new defaults.
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY`, `UBI_OWNER` |
| Clear pregate's test tier on a machine under 8 cores from CI, the default on gstack ([CI carry](docs/pregate.md#ci-carry)) | push, `gstack-ci-carry wait --base main`, then `gstack-pregate --allow-repo-commands` (a branch behind main first runs `gh workflow run free-tests.yml --ref <branch>`) | Free; reuses the green Free Tests run at your pushed tree | Pushed branch, `gh` |
| Clear pregate's test tier on a machine under 8 cores when no CI run covers your tree ([remote gate](docs/pregate.md#remote-gate), opt-in) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check) | Free | Windows, Git Bash |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check, and a PR that touches path, file-name or process code also runs its changed Windows-safe test files in `Windows Scoped Tests`) | Free | Windows, Git Bash |
| Rerun chosen Windows-safe test files on Windows CI | `gh workflow run windows-scoped-tests.yml --ref <branch> -f files='<file> <file>'` (pick files from `bun run test:windows --list`) | Free CI runners | Pushed branch, `gh` with workflow rights |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
| Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — |
+1 -1
View File
@@ -1 +1 @@
1.94.9.0
1.94.10.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.94.9.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.94.10.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+11 -1
View File
@@ -1696,7 +1696,17 @@ 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 checks run by path. `windows-scoped-tests.yml` (Windows Scoped Tests)
runs on every PR, and when the diff touches path, file-name or process code (the
filter is described at the top of the workflow), one `windows-latest` job runs
the PR's changed test files that are in the curated subset below. Its
`windows-scoped-tests` check passes when nothing is in scope, and a red run
blocks its PR. `windows-setup-e2e.yml` skips its Windows jobs when the only
install change is `package.json`'s version line. Both filters are pinned by
`test/windows-path-scope-workflow.test.ts`. To rerun chosen Windows-safe files on
a branch, dispatch `windows-scoped-tests.yml` with `-f files='<file> <file>'`.
The full 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
--shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per
`windows-latest` job
+2 -1
View File
@@ -18,7 +18,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native
## What we stop doing
- No Windows-only or Gemini-only features, parity work, tuning or polish.
- No new Windows or Gemini CI jobs or eval cells. Windows CI tuning stops.
- No new Windows or Gemini CI jobs or eval cells, apart from the path-scoped Windows checks under Windows CI below (ship-90-90 plan D). Other Windows CI tuning stops.
- A Windows-only or Gemini-only report that doesn't break install, startup or a core workflow closes as won't fix ([triage rules](triage.md#closing-an-issue)), with a link to this page. A report that does break one of them gets a small fix.
## Windows CI
@@ -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 (a red run blocks that pull request, like any failed check), and `Windows Setup E2E` runs only when install files change (a version-only `package.json` change doesn't count).
## What doesn't change
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.94.9",
"version": "1.94.10",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -17,7 +17,7 @@
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"scripts/test-pr-profile.ts": {"blob":"dd0ca92bcf8493fd5c1e9d399cc3bc74ec70c469","since":6,"nonBehavioral":"FREE_ONLY_PR_FILES gains USING_GBRAIN_WITH_GSTACK.md (a root guide no skill prompt or paid fixture reads); it changes only which PR paths select paid cases, never how a case runs or is judged (GST-1027)"},
"scripts/test-pr-profile.ts": {"blob":"b552b8825d693493decef76b05b976858cab087e","since":6,"nonBehavioral":"test-pr-profile.ts lists .github/workflows/windows-scoped-tests.yml as free-lane only; the workflow runs no paid case, so no case selection or verdict can change"},
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
"test/helpers/agent-sdk-runner.ts": {"blob":"d66dac458b8c13250973e550edf8087d4044cd0b","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets. Also: runAgentSdkTest now validates systemPrompt before starting; every live caller already passed the claude_code preset, so the prompt the model sees is unchanged and no verdict can change"},
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
+1
View File
@@ -295,6 +295,7 @@ export const FREE_ONLY_PR_FILES = [
'.github/workflows/version-gate.yml',
'.github/workflows/windows-free-tests.yml',
'.github/workflows/windows-pregate-e2e.yml',
'.github/workflows/windows-scoped-tests.yml',
'.github/workflows/windows-setup-e2e.yml',
'.github/workflows/windows-smoke.yml',
] as const;
+2 -1
View File
@@ -7,6 +7,7 @@ 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 { bunIsolationArgs } from '../lib/bun-isolation';
import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECURITY_LINE, matchRisk } from '../lib/review-risk';
const ROOT = path.join(import.meta.dir, '..');
@@ -51,7 +52,7 @@ describe('the rules hashes cover every new pattern', () => {
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
const hash = () => {
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
return r.stdout.trim();
};
+1 -1
View File
@@ -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<any>(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: [] });
+311
View File
@@ -0,0 +1,311 @@
/**
* 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 { afterAll, 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<string, string>, change: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-'));
const write = (files: Record<string, string>) => {
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<<DELIMITER blocks. */
function parseOutputs(text: string): Record<string, string> {
const out: Record<string, string> = {};
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<string, string>; summary: string; temp: string }
const TEMPS: string[] = [];
afterAll(() => { for (const dir of TEMPS) fs.rmSync(dir, { recursive: true, force: true }); });
const newTemp = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-temp-')); TEMPS.push(dir); return dir; };
/** 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<string, string> = {}, temp = newTemp()): 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<string, string> = {}) {
const base: Record<string, string> = {};
const change: Record<string, string> = {};
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<string, string>, change: Record<string, string>) => {
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');
expect(scope({ 'package.json': PKG('1.0.0', '"version": "1.0.0"') }, { 'package.json': PKG('1.0.0', '"version": "1.0.1"') })).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)('the select step reads the real curated plan: a changed Windows-safe test is picked, a POSIX-only one is not', () => {
const temp = newTemp();
fs.writeFileSync(path.join(temp, 'changed-files'), 'freeze/bin/check-freeze.sh\ntest/hook-scripts.test.ts\ntest/windows-path-scope-workflow.test.ts\n');
const select = runStep(selectScript, ROOT, { PATH: `${toShell(path.dirname(process.execPath))}:${process.env.PATH}` }, temp);
expect(select.status, select.stderr).toBe(0);
expect(select.outputs.files).toBe('test/hook-scripts.test.ts');
}, 60_000);
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<string, string>]>) {
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<string, string>) => 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);
});
});