mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 17:58:13 +01:00
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>
This commit is contained in:
1 parent
923fc0a6b8
commit
8d796ac3d2
6 files changed
+618
-1
No files matched your search
@@ -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<<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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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<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 }
|
||||
|
||||
/** 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 = 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<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');
|
||||
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<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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user