diff --git a/.github/workflows/free-durations-record.yml b/.github/workflows/free-durations-record.yml
index 8faa4598f..85749eea0 100644
--- a/.github/workflows/free-durations-record.yml
+++ b/.github/workflows/free-durations-record.yml
@@ -164,7 +164,7 @@ jobs:
- Files that failed while recording (durations kept; this PR's own free checks decide): $failed_line
- Seed growth ratchet (60 s ceiling, shrink-only allowlist): $ratchet_line
- Data only. This run dispatched Free Tests (the required free-tests check), Windows Free Tests and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks.
+ Data only. This run dispatched Free Tests (the required free-tests check), Windows Smoke and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks.
EOF
if [ -n "$number" ]; then
gh pr edit "$number" --body-file pr-body.md
@@ -172,5 +172,5 @@ jobs:
gh pr create --base main --head "$branch" --title "test(seed): refresh the free-suite duration seeds" --body-file pr-body.md
fi
gh workflow run free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
- gh workflow run windows-free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
+ gh workflow run windows-smoke.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run quality-gate.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml
index 33e896c42..6146e927d 100644
--- a/.github/workflows/windows-free-tests.yml
+++ b/.github/workflows/windows-free-tests.yml
@@ -1,9 +1,15 @@
name: Windows Free Tests
-# The Windows-safe subset of the free suite on GitHub's free `windows-latest`
-# runners. scripts/test-free-shards.ts --windows-only is the single source of
-# truth for which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp
-# paths, /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by
+# Nightly, not a PR gate. Windows has minimal support (docs/support-tiers.md):
+# the required Windows check is windows-smoke.yml (./setup plus the core
+# skills). This workflow runs the curated Windows-safe subset of the free suite
+# on main every night and on workflow_dispatch, and never blocks a pull
+# request. A red nightly run gets a fix only when it shows an install, startup
+# or core-workflow break; other Windows-only failures close as won't fix.
+#
+# scripts/test-free-shards.ts --windows-only is the single source of truth for
+# which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp paths,
+# /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by
# pattern there, with explicit force-include and known-incompatible lists.
# Every run prints the curated count and top exclusion reasons in the job
# summary so a shrinking subset is visible.
@@ -27,13 +33,9 @@ name: Windows Free Tests
# dispatch and duration-recording runs never consume.
on:
- pull_request:
- branches: [main]
- # Also on main pushes, as free-tests.yml does: trains dispatch only the
- # Linux free workflow, so without this no Windows run ever sees a train's
- # combined tree or main after a merge.
- push:
- branches: [main]
+ # 09:17 UTC, about 2 AM Pacific, on the default branch (main).
+ schedule:
+ - cron: '17 9 * * *'
workflow_dispatch:
inputs:
record_durations:
@@ -113,8 +115,10 @@ jobs:
if-no-files-found: error
windows-free-shard:
+ # gstack-pregate reads a job keyed on a dispatch input as manual, never a
+ # PR obligation; this lane runs only nightly or by hand.
needs: [windows-plan, reuse]
- if: ${{ !cancelled() && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
+ if: ${{ !cancelled() && !inputs.record_durations && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: windows-latest
timeout-minutes: 15
strategy:
@@ -139,14 +143,8 @@ jobs:
name: windows-plan
path: ${{ runner.temp }}
- # bun's install cache lives under ~/.bun/install/cache on every platform.
- - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
- with:
- path: ~/.bun/install/cache
- key: windows-bun-${{ hashFiles('bun.lock') }}
- # A lockfile bump starts from the previous cache instead of cold.
- restore-keys: |
- windows-bun-
+ # No bun install cache: restoring the ~450 MB entry took 33-42 s per
+ # shard, a cold install 16-17 s (GSTA-203 PR 1 A/B, 3 runs per arm).
- name: Configure git identity (required by tests that init temp repos)
run: |
diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml
index 6b2b928fe..2ca74e7b4 100644
--- a/.github/workflows/windows-setup-e2e.yml
+++ b/.github/workflows/windows-setup-e2e.yml
@@ -92,16 +92,8 @@ jobs:
with:
bun-version: 1.4.2
- # Same lockfile-keyed install cache as windows-free-tests.yml (install
- # was 45s of a 64s job, all network).
- - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
- with:
- path: ~/.bun/install/cache
- key: windows-bun-${{ hashFiles('bun.lock') }}
- # A lockfile bump starts from the previous cache instead of cold
- # (restore alone costs ~43s; without this a bump pays it for nothing).
- restore-keys: |
- windows-bun-
+ # No bun install cache, as in windows-free-tests.yml: a cold install is
+ # faster on windows-latest than restoring the ~450 MB cache entry.
- name: Configure git identity
run: |
diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml
new file mode 100644
index 000000000..525f50553
--- /dev/null
+++ b/.github/workflows/windows-smoke.yml
@@ -0,0 +1,170 @@
+name: Windows Smoke
+
+# The required Windows check under minimal support (docs/support-tiers.md):
+# with a default Git for Windows install, ./setup installs and the core skills
+# start. One windows-latest job, under 5 minutes:
+# 1. ./setup --host claude into an isolated HOME under Git Bash, with
+# gstack's own browser (Playwright Chromium).
+# 2. The core skills start: gstack-skill-start for /ship, /review,
+# /qa-only, /investigate and /browse answers SKILL_START_PROTO: 1.
+# 3. /browse on gstack's own browser: the installed browse runs a piped
+# chain flow against a local page from an unrelated directory.
+# 4. The fastest Windows-safe free tests for those paths (list below).
+# The full curated Windows suite is windows-free-tests.yml: nightly on main
+# and on dispatch, never a PR gate.
+#
+# No bun install cache: a cold install took 16-17 s on windows-latest, a
+# restore of the ~450 MB cache 33-42 s (GSTA-203 PR 1 A/B, 3 runs per arm).
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+concurrency:
+ group: windows-smoke-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ windows-smoke:
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.4.2
+
+ - name: Configure git identity (required by tests that init temp repos)
+ run: |
+ git config --global user.email "windows-smoke@gstack.test"
+ git config --global user.name "Windows Smoke"
+ git config --global init.defaultBranch main
+ shell: bash
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+ shell: bash
+
+ - name: ./setup installs into an isolated HOME
+ run: |
+ set -euo pipefail
+ SMOKE_HOME="$RUNNER_TEMP/smoke-home"
+ mkdir -p "$SMOKE_HOME"
+ HOME="$SMOKE_HOME" USERPROFILE="$(cygpath -w "$SMOKE_HOME")" ./setup --host claude
+ test -x "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" || (echo "MISSING: installed gstack-skill-start" && exit 1)
+ shell: bash
+
+ - name: The core skills start
+ run: |
+ set -euo pipefail
+ SMOKE_HOME="$RUNNER_TEMP/smoke-home"
+ USERPROFILE="$(cygpath -w "$SMOKE_HOME")"
+ export HOME="$SMOKE_HOME" USERPROFILE
+ for skill in ship review qa-only investigate browse; do
+ set +e
+ OUT=$(timeout 120 "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" --skill "$skill" --model claude 2>&1 < /dev/null)
+ CODE=$?
+ set -e
+ echo "::group::$skill"; echo "$OUT"; echo "::endgroup::"
+ test "$CODE" -eq 0 || (echo "/$skill: gstack-skill-start exited $CODE" && exit 1)
+ grep -q '^SKILL_START_PROTO: 1' <<< "$OUT" || (echo "/$skill: no SKILL_START_PROTO: 1 line" && exit 1)
+ done
+ shell: bash
+
+ - name: /browse on gstack's own browser runs a piped chain flow from an unrelated directory
+ run: |
+ set -euo pipefail
+ SMOKE_HOME="$RUNNER_TEMP/smoke-home"
+ USERPROFILE="$(cygpath -w "$SMOKE_HOME")"
+ export HOME="$SMOKE_HOME" USERPROFILE
+ BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse.exe"
+ [ -f "$BROWSE" ] || BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse"
+ test -f "$BROWSE" || (echo "MISSING: installed browse" && exit 1)
+ bun -e 'Bun.serve({ hostname: "127.0.0.1", port: 47124, fetch: () => new Response("
gstack smokegstack-windows-smoke-ok
", { headers: { "content-type": "text/html" } }) }); setInterval(() => {}, 1 << 30);' &
+ PAGE_PID=$!
+ trap 'kill "$PAGE_PID" 2>/dev/null || true; "$BROWSE" stop >/dev/null 2>&1 || true' EXIT
+ mkdir -p "$RUNNER_TEMP/unrelated-dir" && cd "$RUNNER_TEMP/unrelated-dir"
+ set +e
+ OUT=$(printf '%s\n' '[["goto","http://127.0.0.1:47124/"],["text"]]' | "$BROWSE" chain 2>&1)
+ CODE=$?
+ set -e
+ echo "$OUT"
+ if [ "$CODE" -ne 0 ]; then
+ find "$RUNNER_TEMP/unrelated-dir" "$SMOKE_HOME" -maxdepth 4 \( -name 'browse-daemon.log' -o -name 'browse-startup-error.log' \) -print -exec tail -n 60 {} \; 2>/dev/null || true
+ echo "browse chain exited $CODE" && exit 1
+ fi
+ grep -q 'gstack-windows-smoke-ok' <<< "$OUT" || (echo "chain output lacks the page text: the flow did not run" && exit 1)
+ shell: bash
+
+ - name: Fastest Windows-safe free tests for install and the core skills
+ # Windows-measured seconds from scripts/free-test-durations-windows.json.
+ # setup-windows-fallback (0.1): setup's _link_or_copy copies, never links, on Windows
+ # setup-staged-copy (0.6): a copy install is staged and swapped in only when complete
+ # setup-windows-rerun-refresh (1.7): re-running ./setup refreshes copied skills (#2444)
+ # setup-skill-copies (0.1): copied SKILL.md hashes and the backup of an edited copy
+ # setup-bun-cmd-and-pipe-bugs (0.4): setup's bun.cmd and pipe handling
+ # gstack-launch-probe (0.1): setup's Smart App Control launch probe (#2595)
+ # state-root-parity (2.1): every skill start resolves one state root in TS and bash
+ # bun-isolation-launch (1.1): Bun entries ignore .env and bunfig under Git Bash (#3415)
+ # gstack-lease-git-bash (3.7): the skill-start lease under Git Bash (#3330)
+ # gstack-wtree-windows-paths (1.3): /review and /ship record a start on C:/ paths (#3101)
+ # ship-hook-windows-paths (2.3): /ship's pre-push guard in a Windows worktree (#2805)
+ # qa-only-browser-probe (2.4): /qa-only's browser probe fixture
+ # find-browse (0.1): /browse resolves its binary (.exe)
+ # cli-chain-stdin (0.3): /browse chain reads its flow from stdin on Windows (#3039)
+ # windows-spawn-hide (0.1): /browse's spawns hide their console window (#1835)
+ env:
+ GSTACK_FREE_RETRY_FLAKY: '1'
+ GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
+ TEMP: ${{ runner.temp }}
+ TMP: ${{ runner.temp }}
+ run: |
+ set -euo pipefail
+ cat > "$RUNNER_TEMP/windows-smoke-files.txt" <<'LIST'
+ test/setup-windows-fallback.test.ts
+ test/setup-staged-copy.test.ts
+ test/setup-windows-rerun-refresh.test.ts
+ test/setup-skill-copies.test.ts
+ test/setup-bun-cmd-and-pipe-bugs.test.ts
+ test/gstack-launch-probe.test.ts
+ test/state-root-parity.test.ts
+ test/bun-isolation-launch.test.ts
+ test/gstack-lease-git-bash.test.ts
+ test/gstack-wtree-windows-paths.test.ts
+ test/ship-hook-windows-paths.test.ts
+ test/qa-only-browser-probe.test.ts
+ browse/test/find-browse.test.ts
+ browse/test/cli-chain-stdin.test.ts
+ browse/test/windows-spawn-hide.test.ts
+ LIST
+ bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"
+ shell: bash
+
+ - name: Upload full test logs
+ if: always()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+ with:
+ name: windows-smoke-logs
+ path: |
+ .context/free-test-logs/gstack-free-test-*.log
+ ${{ runner.temp }}/gstack-free-test-*.log
+ include-hidden-files: true
+ if-no-files-found: ignore
+
+ - name: Upload flake ledger
+ if: always()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+ with:
+ name: flake-ledger-windows-smoke
+ path: ${{ runner.temp }}/flake-ledger.jsonl
+ if-no-files-found: ignore
+ retention-days: 90
diff --git a/.gstack/pregate.json b/.gstack/pregate.json
index c424957cc..74a99f1ad 100644
--- a/.gstack/pregate.json
+++ b/.gstack/pregate.json
@@ -16,8 +16,7 @@
"supabase/**": []
},
"selection": {
- "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list",
- "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list"
+ "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list"
},
"run_profiles": {
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json
index 2ae73849b..a37b11ca4 100644
--- a/.gstack/ship-policy.json
+++ b/.gstack/ship-policy.json
@@ -14,6 +14,6 @@
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
"maintainers": ["garrytan"],
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
- "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
+ "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
}
diff --git a/AGENTS.md b/AGENTS.md
index febab17af..1b724461b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -285,9 +285,11 @@ Support tiers (full: macOS, Linux; minimal: Windows, Gemini models) are in
[docs/support-tiers.md](docs/support-tiers.md).
- **macOS** + **Linux**: full test suite supported.
-- **Windows**: the curated Windows-safe subset runs in the `windows-free-tests`
- CI workflow across six `windows-latest` jobs, packed by Windows-measured
- durations. Setup script (`./setup`) requires Git Bash or
+- **Windows**: minimal support; [docs/support-tiers.md](docs/support-tiers.md)
+ says what is promised. Every PR must pass the `windows-smoke` check (`./setup`,
+ the core skills start, the fastest Windows-safe tests); the curated
+ Windows-safe subset runs nightly on main in `windows-free-tests` and never
+ blocks a PR. Setup script (`./setup`) requires Git Bash or
MSYS today; native PowerShell support is a future expansion. The `bin/gstack-paths`
helper resolves state roots through `CLAUDE_PLUGIN_DATA` / `GSTACK_HOME` so plugin
installs work on every platform.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33db702f7..0b195eea2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## [1.92.51.0] - 2026-11-01
+
+**PRs no longer wait on the six-shard Windows suite. A short `windows-smoke` check is the required Windows check, and the full suite runs nightly on main.** Windows has minimal support (docs/support-tiers.md). Since 2026-11-01 06:00 UTC, Windows Free Tests finished last on 71 of 73 PR heads, with a wall time of 892 s at p50 and 1,886 s at p90 over 71 green PR runs, and each run used about 59 Windows runner-minutes. The new smoke check finished in 198 s on this PR (run 41325664750). With smoke in its place, the slowest required check on a PR drops from 892 s to 562 s at p50 and from 1,877 s to 1,193 s at p90.
+
+### Changed
+- **New `windows-smoke.yml`** (check `windows-smoke`, on pull requests, pushes to main and by hand). It installs gstack with `./setup --host claude` into a separate home, starts /ship, /review, /qa-only, /investigate and /browse with `gstack-skill-start`, runs the installed browse chain against gstack's own browser, and runs 15 Windows-safe test files. Each file's reason is written next to it in the workflow. No cache: a cold `bun install` took 16 to 17 s, while restoring the cache took 33 to 42 s in a three-round A/B.
+- **`windows-free-tests.yml` runs nightly** (09:17 UTC) on main, and by hand. It no longer runs on pull requests or pushes and never blocks a merge. A red nightly gets a fix only when it shows a broken install, startup or core workflow. Other Windows-only failures close as won't fix with a link to docs/support-tiers.md.
+- **Bun cache removed** from `windows-free-tests.yml` and `windows-setup-e2e.yml`. The 18 cache entries on pull-request refs (8.05 GB) were deleted. Main's entries were left alone.
+- **`gstack-ship-queue ready --dispatch-ci`** skips a named workflow that main has but the PR branch predates, and prints a note instead of failing. GitHub reads the workflow file from the branch, so dispatching `windows-smoke.yml` on an older branch could only fail.
+
+### Gate integrity
+- `.gstack/ship-policy.json` `required_checks`: `windows-free-tests` is replaced by `windows-smoke`. The ci-hint, `--dispatch-ci` docs, train CLI help and `free-durations-record.yml` now name `windows-smoke.yml`.
+- `.gstack/pregate.json`: the `windows-free-tests.yml/windows-free-shard` remote lane is removed, since that suite no longer runs on PRs. Linux and macOS lanes are unchanged.
+- `scripts/test-health-report.ts` measures Windows from `windows-smoke.yml` PR runs.
+- No test file was deleted, skipped or re-tiered. `scripts/harness-version.json` records `scripts/test-pr-profile.ts` as a non-behavioral change, because it only lists `windows-smoke.yml` among files no paid case reads.
+
## [1.92.50.0] - 2026-11-01
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6132d56ac..634968ea6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -73,8 +73,8 @@ project where you actually felt the pain.
**What CI does on a fork PR.** GitHub never gives a fork PR this repository's
secrets, so CI runs everything that needs none: the required `free-tests` check
-(the Linux free suite, typecheck, the macOS and Windows gates), Windows Free
-Tests, Skill Docs Freshness, Workflow Lint, Version Gate, Quality gate, the
+(the Linux free suite, typecheck, the macOS and Windows gates), Windows
+Smoke, Skill Docs Freshness, Workflow Lint, Version Gate, Quality gate, the
make-pdf gate, and any path-triggered gates your change touches. E2E Evals
still builds the CI image from your `Dockerfile.ci` without publishing it, and
skips its paid eval jobs, which need provider API keys. That skip is expected
@@ -192,7 +192,7 @@ the new defaults.
| Run full free acceptance before publishing | `bun run test` | Free, a few minutes | Bun 1.4.2 |
| 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 ([remote gate](docs/pregate.md#remote-gate)) | `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) | 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) | Free | Windows, Git Bash |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref -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 -f 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 | — |
diff --git a/TODOS.md b/TODOS.md
index a95320ccd..eb40e432b 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -17,6 +17,12 @@
- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1.
- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1.
+### P2/P3: Windows smoke follow-ups (filed 2026-11-01, from the GSTA-203 PR 1 /ship review)
+- **Branches cut before windows-smoke.yml still owe the old Windows lane (P2)** — pregate reads lanes from the branch's own workflows, so such a branch keeps an uncleared `windows-free-tests.yml/windows-free-shard` obligation, and `ready --dispatch-ci` skips `windows-smoke.yml` there. The lane clears it by passing `windows-free-tests.yml` to `--dispatch-ci` (the old cost) or with a main-merge grant. Have the skip note name that route, or dispatch the old workflow in smoke's place. The skip also treats a branch that deletes or renames `windows-smoke.yml` as one that predates it; check the merge base too. **Effort:** S. **Priority:** P2.
+- **Someone owns the nightly (P2)** — nothing reads the scheduled `windows-free-tests.yml` result or its flake ledgers. Add a test-health-report metric for scheduled runs on main, or have the daily flaky pass read the latest nightly. **Effort:** S. **Priority:** P2.
+- **Smoke /browse step waits for its page server (P3)** — `windows-smoke.yml` starts `Bun.serve` in the background and calls `browse chain goto` right away; poll the port first. **Effort:** XS. **Priority:** P3.
+- **Smoke seed ceiling vs the weekly reseed (P3)** — `test/windows-native-workflows.test.ts` holds each smoke file to 5 s in the Windows seed, which `free-durations-record.yml` rewrites weekly; one slow recording turns the seed PR red. Report the overrun there or re-pick files at reseed. **Effort:** S. **Priority:** P3.
+
### P2/P3: pregate head-policy follow-ups (filed 2026-10-31, from the GST-419/GST-420 /ship review)
- **`finish` fallback drops head-policy lanes (P2)** — `lib/ship-release.ts` reruns pregate on the stamped tree under the base policy, so after a version bump a lane the branch added under `--policy-from head` is gone and `finish` reports `PREGATE: fail (rerun on the stamped tree)`. GSTA-194 owns that file and has the note. **Effort:** S. **Priority:** P2.
- **The 3600 s preflight and `finish` wrapper deadline (P3)** — it caps the whole pregate run, so several runners that each fit `timeout_s` can still be cut off together. Derive the wrapper deadline from `timeout_s` and the lane count, or document the cap. **Effort:** S. **Priority:** P3.
diff --git a/VERSION b/VERSION
index 89ada282a..fdd84fc4e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.92.50.0
+1.92.51.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index beefb3031..306c1f587 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.92.50.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.92.51.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,
diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md
index caa047811..ea9a9f4ca 100644
--- a/docs/TESTING_INTERNALS.md
+++ b/docs/TESTING_INTERNALS.md
@@ -1547,7 +1547,7 @@ refresh to a maintainer.
(`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds
or drops a test file, it force-updates the `bot/free-duration-seeds` branch,
opens or refreshes one seed PR whose body states the ratchet result, and
- dispatches Free Tests, Windows Free Tests and Quality gate on it. Those
+ dispatches Free Tests, Windows Smoke and Quality gate on it. Those
dispatched runs are the seed PR's checks, and Free Tests supplies the required
`free-tests` check. GitHub holds the pull_request runs of a token-opened PR as
"action required" until someone approves them. A file that fails while recording turns its
@@ -1561,6 +1561,13 @@ refresh to a maintainer.
### Windows free lane
+Windows has minimal support ([support tiers](support-tiers.md)). The required
+PR check is `windows-smoke.yml`: one `windows-latest` job runs `./setup`, starts
+the core skills, drives `/browse` on gstack's own browser, and runs the short
+test list in its last step, where each file's reason sits beside it.
+`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast.
+
+The curated suite below runs nightly on main and on dispatch, never on a PR.
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6`),
runs one strict serial shard per `windows-latest` job
(`bun run test:windows --ci-run`), and verifies every shard receipt on Linux
diff --git a/docs/pregate.md b/docs/pregate.md
index 057788ade..def98ef6a 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -848,7 +848,9 @@ path records and uploads `tested-tree`, after its verification passes, so a
red, partial or reused run never leaves a record and a reuse is never reused.
The pull request lookup needs no `pull-requests` permission on a public
repository; a private fork of this setup would need `pull-requests: read`,
-and without it every push falls back to the full run.
+and without it every push falls back to the full run. Windows Free Tests now
+runs nightly and by hand only (docs/support-tiers.md), never on a push, so its
+`reuse` job is always skipped and every nightly run takes the strict path.
## Guards and the lane-done rule
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index 4e0488229..0d3fc4493 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -92,7 +92,7 @@ hard-code:
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"],
- "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
+ "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
}
```
@@ -210,16 +210,18 @@ READY PR as conflicting each time main stamps; the train merges the CHANGELOG
by section, so the lane never merges main for it. The conflict does mean GitHub
has no merge ref for the PR, so no `pull_request` CI runs on the head and
`ready` prints `ci: none at ` with a `ci-hint:` line. The lane then
-runs `ready … --dispatch-ci free-tests.yml,windows-free-tests.yml [--timeout
+runs `ready … --dispatch-ci free-tests.yml,windows-smoke.yml [--timeout
]` (under a train policy): it dispatches each named workflow on the branch
(`gh workflow run --ref `, skipped when a run at this head already
-concluded), waits for the runs (`CI_DISPATCH: --ref run
-`), clears every requires-remote pregate lane whose workflow is one
-of them with the run URL (`PREGATE_CLEARED: `; a lane of another
-workflow stays uncleared and is named in the refusal), and then reads the head
-as usual, which passes because the dispatched check runs attach to the commit;
-the `ci-hint:` line then says those non-pull_request runs count. A red run
-clears no lane and refuses (`QUEUE_NOT_READY`); a run still going at `--timeout`
+concluded, and skipped with a note when the base has the workflow file but the
+branch predates it, since GitHub reads the file from the branch), waits for the
+runs (`CI_DISPATCH: --ref run `), clears
+every requires-remote pregate lane whose workflow is one of them with the run
+URL (`PREGATE_CLEARED: `; a lane of another workflow stays
+uncleared and is named in the refusal), and then reads the head as usual,
+which passes because the dispatched check runs attach to the commit; the
+`ci-hint:` line then says those non-pull_request runs count. A red run clears
+no lane and refuses (`QUEUE_NOT_READY`); a run still going at `--timeout`
(default 2700 s) is `QUEUE_TRAIN_PENDING`, and rerunning the command keeps
waiting; a fork head is never dispatched here (`QUEUE_TRAIN_UNTRUSTED`). By hand the same four steps are `gh workflow
run --ref `, wait, `gstack-pregate clear --remote --run-url
diff --git a/lib/ship-queue-dispatch-ci.ts b/lib/ship-queue-dispatch-ci.ts
index 0c496ab8c..d099b75c7 100644
--- a/lib/ship-queue-dispatch-ci.ts
+++ b/lib/ship-queue-dispatch-ci.ts
@@ -9,7 +9,10 @@
* run at this head already concluded), a wait for each run, and one
* `PREGATE_CLEARED:` per requires-remote lane whose workflow is one of them
* and whose run concluded success. A red run clears no lane and refuses. A
- * fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED).
+ * fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED). A workflow the
+ * base has but the head predates (windows-smoke.yml on a branch cut before it
+ * landed) is skipped with a note: GitHub reads the workflow file from the
+ * branch, so the dispatch could only fail.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -17,6 +20,7 @@ import type { PregateFile } from './headless-artifacts';
import { PREGATE_OUT_REL, clearObligation, readPregateFile, writePregateFile } from './pregate/index';
import type { QueueContext } from './ship-queue';
import type { QueueFailure } from './ship-queue-gate';
+import { git } from './ship-queue-gate';
import { prInfo, repoSlug, runView } from './ship-queue-train-github';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -47,6 +51,11 @@ export async function dispatchCi(ctx: QueueContext, o: DispatchCiOptions): Promi
const prior = atHead.find(r => r.status === 'completed');
if (prior) { lines.push(`CI_DISPATCH: ${wf} not dispatched (run ${prior.databaseId} already concluded ${prior.conclusion ?? 'unknown'} at this head)`); concluded.set(wf, prior); continue; }
const live = atHead[0];
+ const wfPath = `.github/workflows/${wf}`;
+ if (!live && git(ctx.repoRoot, ['cat-file', '-e', `${head}^{commit}`]).ok && git(ctx.repoRoot, ['cat-file', '-e', `${ctx.remote ?? 'origin'}/${ctx.base}:${wfPath}`]).ok && !git(ctx.repoRoot, ['cat-file', '-e', `${head}:${wfPath}`]).ok) {
+ lines.push(`CI_DISPATCH: ${wf} not dispatched (${wfPath} is on ${ctx.base} but not in ${head.slice(0, 12)}; the branch predates it, so its pull_request run comes with the stamp commit)`);
+ continue;
+ }
if (!live) {
const d = ctx.gh(['workflow', 'run', wf, '--ref', branch]);
if (!d.ok) return { lines, failure: { code: 'QUEUE_NOT_READY', message: `gh workflow run ${wf} --ref ${branch} failed (${(d.err ?? d.out).trim().replace(/\s+/g, ' ').slice(0, 120) || 'no output'})` } };
diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts
index fe561b3c9..a4ce4e5a6 100644
--- a/lib/ship-queue-train-cli.ts
+++ b/lib/ship-queue-train-cli.ts
@@ -174,7 +174,7 @@ export async function readyMain(ctx: QueueContext, args: string[], base: { pr: s
if (args.includes('--broad') && !screen) return usage('--broad selects the screen policy; it needs --screen');
const dispatchCi = flag(args, '--dispatch-ci');
const workflows = dispatchCi === undefined ? [] : dispatchCi.split(',').map(s => s.trim()).filter(Boolean);
- if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-free-tests.yml)');
+ if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-smoke.yml)');
const timeoutSec = positive(flag(args, '--timeout'), 2700);
if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds');
const fixer = flag(args, '--fixer');
diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts
index ae3925ece..09471df27 100644
--- a/lib/ship-queue.ts
+++ b/lib/ship-queue.ts
@@ -175,7 +175,7 @@ function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean },
if (mergeable !== 'CONFLICTING') return null;
const ref = branch && /^[\w.][\w./-]*$/.test(branch) ? `--ref ${branch}` : '--ref ';
if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`;
- if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`;
+ if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`;
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`;
return null;
}
diff --git a/package.json b/package.json
index 59700dfe3..56d0c665c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gstack",
- "version": "1.92.50",
+ "version": "1.92.51",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
diff --git a/scripts/harness-version.json b/scripts/harness-version.json
index 99304ffaf..1e091511b 100644
--- a/scripts/harness-version.json
+++ b/scripts/harness-version.json
@@ -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":"911e393d0c36c050c3a68db5a81ef21701bfd5ae","since":6,"nonBehavioral":"selection only: fragment exemption narrowed to .md files directly in the fragments dir; changed-file lists include a rename's old path; no case prompt, fixture, judge or verdict logic changes (GST-400)"},
+ "scripts/test-pr-profile.ts": {"blob":"25e2d88dd2e1caae5213fb396b977891bc67549a","since":6,"nonBehavioral":"scripts/test-pr-profile.ts only lists .github/workflows/windows-smoke.yml among files no paid case reads; no case's selection, prompt or verdict can change"},
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
diff --git a/scripts/test-health-report.ts b/scripts/test-health-report.ts
index 8d9e55168..7d89e60c9 100644
--- a/scripts/test-health-report.ts
+++ b/scripts/test-health-report.ts
@@ -292,13 +292,13 @@ export function collectHealth(inputs: HealthInputs): HealthReport {
detail: window.files.slice(0, 15).map(file => `${file.file}: ${file.flakyRuns}/${window.runs} runs (${pct(file.rate)})${file.failing ? ' FAIL > 5%' : ''}`) };
}));
- metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-free-tests.yml (its workflow_dispatch duration recordings excluded)', () => {
+ metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-smoke.yml, the required Windows check (its workflow_dispatch runs excluded)', () => {
const walls = (workflow: string, filter: { branch?: string; event?: string }) =>
listWorkflowRuns(client, repo, workflow, { since, ...filter }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!);
const free = walls('free-tests.yml', { branch: 'main' });
- const windows = walls('windows-free-tests.yml', { event: 'pull_request' });
+ const windows = walls('windows-smoke.yml', { event: 'pull_request' });
const windowsValue = windows.length ? `${minutes(percentile(windows, 0.5))} (${windows.length} runs)` : 'no successful PR runs in window';
- return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-free-tests (successful PR runs) ${windowsValue}` };
+ return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-smoke (successful PR runs) ${windowsValue}` };
}));
metrics.push(metric('free-serial', 'Free-suite serial time', 'sum of scripts/free-test-durations.json (the committed seed)', () => {
diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts
index 911e393d0..25e2d88dd 100644
--- a/scripts/test-pr-profile.ts
+++ b/scripts/test-pr-profile.ts
@@ -294,6 +294,7 @@ export const FREE_ONLY_PR_FILES = [
'.github/workflows/windows-free-tests.yml',
'.github/workflows/windows-pregate-e2e.yml',
'.github/workflows/windows-setup-e2e.yml',
+ '.github/workflows/windows-smoke.yml',
] as const;
/**
diff --git a/test/ci-reuse-workflow-wiring.test.ts b/test/ci-reuse-workflow-wiring.test.ts
index 0ad60e306..6d9b3b8d5 100644
--- a/test/ci-reuse-workflow-wiring.test.ts
+++ b/test/ci-reuse-workflow-wiring.test.ts
@@ -15,7 +15,7 @@ import { parseWorkflow } from '../lib/pregate/workflows';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
-const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[] }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'] } };
+const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[], manual: false }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'], manual: true } };
const REUSE_IF = "github.event_name == 'push' && github.ref == 'refs/heads/main'";
const SKIP = "steps.route.outputs.reused != 'true'";
const PROFILES = JSON.parse(read('.gstack/pregate.json')).run_profiles as Record;
@@ -74,8 +74,10 @@ for (const [file, spec] of Object.entries(FILES)) {
for (const name of Object.keys(jobs).filter(downstream)) expect([name, jobs[name].if]).toEqual([name, expect.stringMatching(/!cancelled\(\)|always\(\)/)]);
});
- test('pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
- for (const lane of parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job))) expect([lane.id, lane.manual]).toEqual([lane.id, false]);
+ test(spec.manual ? 'pregate reads every test lane as manual: the nightly Windows suite is never a PR obligation (windows-smoke.yml is)' : 'pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
+ const lanes = parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job));
+ expect(lanes.length).toBeGreaterThan(0);
+ for (const lane of lanes) expect([lane.id, lane.manual]).toEqual([lane.id, spec.manual]);
});
test('the aggregate routes first, before any download; the strict path follows unchanged and alone uploads tested-tree after verification', () => {
diff --git a/test/free-durations-record-workflow.test.ts b/test/free-durations-record-workflow.test.ts
index 2ba947cbf..85f15f216 100644
--- a/test/free-durations-record-workflow.test.ts
+++ b/test/free-durations-record-workflow.test.ts
@@ -82,7 +82,7 @@ test('the propose step touches only the two seeds, never the allowlist, on one b
expect(script).not.toContain('free-test-seed-allowlist.json');
expect(script).toContain('branch=bot/free-duration-seeds');
expect(script).toContain('bun test test/free-seed-ratchet.test.ts');
- for (const workflow of ['free-tests.yml', 'windows-free-tests.yml', 'quality-gate.yml']) {
+ for (const workflow of ['free-tests.yml', 'windows-smoke.yml', 'quality-gate.yml']) {
expect(script).toContain(`gh workflow run ${workflow} --repo "$GITHUB_REPOSITORY" --ref "$branch"`);
expect(load(workflow).on).toHaveProperty('workflow_dispatch');
}
diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts
index 9b9248631..fab39a52a 100644
--- a/test/gstack-ship-queue.test.ts
+++ b/test/gstack-ship-queue.test.ts
@@ -562,10 +562,10 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts,
const { r, at } = readyWith('CONFLICTING', { check_runs: [] });
expect(r.status).toBe(EXIT.refused);
expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`);
- expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`);
+ expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-smoke.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`);
for (const name of ['x;$(curl evil)', '--field']) {
const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: name });
- expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref && gh workflow run windows-free-tests.yml --ref ');
+ expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref && gh workflow run windows-smoke.yml --ref ');
}
const fork = readyWith('CONFLICTING', { check_runs: [] }, { isCrossRepository: true });
expect(line(fork.r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${fork.at}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`);
diff --git a/test/ship-queue-train-ops.test.ts b/test/ship-queue-train-ops.test.ts
index 61df3d0dd..c20468fb8 100644
--- a/test/ship-queue-train-ops.test.ts
+++ b/test/ship-queue-train-ops.test.ts
@@ -449,6 +449,33 @@ describe('ready --dispatch-ci', () => {
expect([fork.status, fork.err]).toEqual([EXIT.refused, expect.stringMatching(/#1's head is on a fork.*not dispatched.*\(QUEUE_TRAIN_UNTRUSTED\)/)]);
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', '../evil.yml']).status).toBe(EXIT.usage);
}, 120_000);
+
+ // Value: protects=a conflicting PR cut before main gained windows-smoke.yml still gets its free-tests dispatch when the hint names both workflows (GitHub reads the workflow file from the branch, so dispatching the missing one could only fail);
+ // fails_when=the missing workflow is dispatched, a missing workflow refuses the whole command, or a workflow the branch has is skipped; why_new=windows-smoke.yml replaced windows-free-tests.yml in the hint while open branches predate it; seam=git objects in the fixture checkout; no_claim=GitHub's exact error for a workflow missing at the ref
+ test('skips a workflow the base has but the PR head predates, with a note, and dispatches the rest', () => {
+ const { fx, head, pregate } = readyFixture();
+ const { repo } = fx;
+ repo.git('checkout', '-q', 'origin/main');
+ fs.mkdirSync(path.join(repo.work, '.github', 'workflows'), { recursive: true });
+ fs.writeFileSync(path.join(repo.work, '.github', 'workflows', 'windows-smoke.yml'), 'name: Windows Smoke\n');
+ repo.git('add', '-A');
+ repo.git('commit', '-q', '-m', 'add windows-smoke.yml');
+ repo.git('push', '-q', 'origin', 'HEAD:main');
+ repo.git('fetch', '-q', 'origin', 'main');
+ repo.git('checkout', '-q', 'pr-1');
+ fx.write(d => {
+ (d.prs['1'] as any).mergeable = 'CONFLICTING';
+ d.ci[head] = [];
+ d.rules['free-tests.yml'] = { ci: 'free-tests', headSha: head };
+ d.rules['windows-smoke.yml'] = { ci: 'windows-smoke', headSha: head };
+ });
+ const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml,windows-smoke.yml', '--timeout', '5']);
+ expect(r.out.split('\n').filter(l => l.startsWith('CI_DISPATCH:'))).toEqual([
+ 'CI_DISPATCH: free-tests.yml --ref pr-1 run 100 https://example.invalid/runs/100 success',
+ `CI_DISPATCH: windows-smoke.yml not dispatched (.github/workflows/windows-smoke.yml is on main but not in ${head.slice(0, 12)}; the branch predates it, so its pull_request run comes with the stamp commit)`]);
+ expect(dispatches(fx).map(l => l.workflow)).toEqual(['free-tests.yml']);
+ expect(line(r.out, 'ci:')).toBe(`ci: green at ${head.slice(0, 12)} (passed=1 failed=0 pending=0 skipped=0)`);
+ }, 120_000);
});
describe('train build --dry-run --stack-tests', () => {
diff --git a/test/test-health-report.test.ts b/test/test-health-report.test.ts
index 0d654dba8..41f7d2736 100644
--- a/test/test-health-report.test.ts
+++ b/test/test-health-report.test.ts
@@ -189,7 +189,7 @@ describe('collectHealth over recorded fixture data', () => {
[/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: [run(60), run(61, { conclusion: 'failure' })] })],
[/runs\/60\/artifacts/, () => ({ artifacts: [{ id: 61, name: 'flake-ledger-2', size_in_bytes: 10 }] })],
[/runs\/61\/artifacts/, () => ({ artifacts: [] })],
- [/workflows\/windows-free-tests\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
+ [/workflows\/windows-smoke\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
], {
11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback', e2e: ['gate-a', 'gate-b'], judges: ['judge-a'],
shadow: { e2e: ['gate-a', 'gate-b'], wouldDrop: [], wouldAdd: [], provenance: [], predictedSavingUsd: 0, unpricedCases: 0 } } }) },
@@ -218,7 +218,7 @@ describe('collectHealth over recorded fixture data', () => {
expect(value('census-redispatch').value).toBe('0 of 1 census(es) re-dispatched; longest paid slice job 20.0 min');
expect(value('quarantine-evidence').value).toContain('insufficient evidence (not the same as healthy)');
expect(value('free-flaky-pass-rate').value).toStartWith('50.0% (1/2 main runs');
- expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 0.0 min (1 runs)');
+ expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 0.0 min (1 runs)');
expect(value('free-serial').value).toBe('3 s over 3 files');
for (const entry of report.metrics.filter(entry => entry.id !== 'net-complexity')) {
expect(entry.rule.length).toBeGreaterThan(10);
@@ -287,10 +287,10 @@ describe('collectHealth over recorded fixture data', () => {
const pr = (id: number, minutes: number, extra: Record = {}) => run(id, { event: 'pull_request', head_branch: `pr-${id}`,
run_started_at: day(1), updated_at: new Date(Date.parse(day(1)) + minutes * 60_000).toISOString(), ...extra });
expect(wall([pr(70, 6), pr(71, 7), pr(74, 8), pr(72, 30, { conclusion: 'failure' }), run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
- .toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 7.0 min (3 runs)');
+ .toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 7.0 min (3 runs)');
expect(wall([run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
- .toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) no successful PR runs in window');
- expect(wall([]).rule).toContain('workflow_dispatch duration recordings excluded');
+ .toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) no successful PR runs in window');
+ expect(wall([]).rule).toContain('workflow_dispatch runs excluded');
});
test('a rate-limited API degrades every remote metric to a named unavailable reason, never 0, and keeps the issue open', () => {
diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts
index b35bbe6c2..48ce50d9d 100644
--- a/test/windows-native-workflows.test.ts
+++ b/test/windows-native-workflows.test.ts
@@ -14,6 +14,8 @@ const root = path.resolve(import.meta.dir, '..');
const load = (file: string) => Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows', file), 'utf8')) as any;
const windows = load('windows-free-tests.yml');
const native = load('native-qualification.yml');
+const smoke = load('windows-smoke.yml');
+const smokeText = readFileSync(path.join(root, '.github/workflows/windows-smoke.yml'), 'utf8');
/**
* Ratchet: the Windows-safe subset may not silently shrink. If you deleted or
@@ -22,9 +24,9 @@ const native = load('native-qualification.yml');
*/
const WINDOWS_CURATED_FLOOR = 795;
-test('the Windows lane plans, runs one strict shard per job and verifies every result', () => {
- expect(Object.keys(windows.on).sort()).toEqual(['pull_request', 'push', 'workflow_call', 'workflow_dispatch']);
- expect(windows.on.push).toEqual({ branches: ['main'] });
+test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => {
+ expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']);
+ expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]);
expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations']);
expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']);
const plan = windows.jobs['windows-plan'];
@@ -35,6 +37,7 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
const shard = windows.jobs['windows-free-shard'];
expect(shard['runs-on']).toBe('windows-latest');
expect(shard.needs).toEqual(['windows-plan', 'reuse']);
+ expect(shard.if).toStartWith('${{ !cancelled() && !inputs.record_durations && ');
expect(shard.strategy).toEqual({ 'fail-fast': false, matrix: '${{ fromJSON(needs.windows-plan.outputs.matrix) }}' });
const run = shard.steps.find((step: any) => step.name === 'Run Windows-safe shard');
expect(run.run).toStartWith('bun run test:windows --ci-run "$RUNNER_TEMP/windows-plan.json" --shard ${{ matrix.shard }} --result ');
@@ -77,6 +80,50 @@ test('a record_durations dispatch times each Windows-safe file alone and uploads
expect(job.steps.at(-1).if).toBe("always() && steps.seed.outcome == 'success'");
});
+test('windows-smoke is the PR gate: every pull request and main push, one windows-latest job', () => {
+ expect(Object.keys(smoke.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch']);
+ expect(smoke.on.pull_request).toEqual({ branches: ['main'] });
+ expect(smoke.on.push).toEqual({ branches: ['main'] });
+ expect(smoke.permissions).toEqual({ contents: 'read' });
+ expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']);
+ const job = smoke.jobs['windows-smoke'];
+ expect(job['runs-on']).toBe('windows-latest');
+ expect(job.if).toBeUndefined();
+ const step = (name: string) => job.steps.find((s: any) => s.name === name);
+ expect(step('./setup installs into an isolated HOME').run).toContain('./setup --host claude');
+ const start = step('The core skills start').run;
+ expect(start).toContain('for skill in ship review qa-only investigate browse; do');
+ expect(start).toContain("grep -q '^SKILL_START_PROTO: 1'");
+ expect(step("/browse on gstack's own browser runs a piped chain flow from an unrelated directory").run).toContain('| "$BROWSE" chain');
+ const tests = step('Fastest Windows-safe free tests for install and the core skills');
+ expect(tests.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"');
+ expect(tests.env.GSTACK_FREE_RETRY_FLAKY).toBe('1');
+});
+
+test('every windows-smoke test is Windows-safe, seeded fast, and says why it is there', () => {
+ const run: string = smoke.jobs['windows-smoke'].steps.find((s: any) => s.name === 'Fastest Windows-safe free tests for install and the core skills').run;
+ const files = run.slice(run.indexOf("<<'LIST'\n") + 9, run.indexOf('\nLIST')).split('\n');
+ expect(files.length).toBeGreaterThan(0);
+ expect(new Set(files).size).toBe(files.length);
+ const { safe } = curateWindowsSafe(collectFreeTestFiles(root), root);
+ expect(files.filter(file => !safe.includes(file))).toEqual([]);
+ const seed = JSON.parse(readFileSync(path.join(root, 'scripts/free-test-durations-windows.json'), 'utf8')).durations as Record;
+ expect(files.filter(file => !(seed[file] <= 5_000))).toEqual([]);
+ expect(files.reduce((sum, file) => sum + seed[file]!, 0)).toBeLessThan(60_000);
+ for (const file of files) expect(smokeText).toMatch(new RegExp('# ' + path.basename(file, '.test.ts').replace(/[.-]/g, '\\$&') + ' \\([0-9.]+\\): \\S'));
+});
+
+test('no Windows job restores or saves the bun install cache', () => {
+ // GSTA-203 PR 1: a cold install (16-17 s) beat restoring the ~450 MB
+ // windows-bun entry (33-42 s), and PR merge-ref saves filled the 10 GB quota.
+ for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml']) {
+ for (const [name, job] of Object.entries(load(file).jobs)) {
+ const caches = (job.steps ?? []).filter((step: any) => step.uses?.startsWith('actions/cache'));
+ expect({ file, job: name, caches }).toEqual({ file, job: name, caches: [] });
+ }
+ }
+});
+
test('Windows shards use the pinned Node runtime and retain complete logs on every run', () => {
const steps = windows.jobs['windows-free-shard'].steps;
const node = steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));