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 smoke

gstack-windows-smoke-ok

", { headers: { "content-type": "text/html" } }) }); setInterval(() => {}, 1 << 30);' & + PAGE_PID=$! + trap 'kill "$PAGE_PID" 2>/dev/null || true; "$BROWSE" stop >/dev/null 2>&1 || true' EXIT + mkdir -p "$RUNNER_TEMP/unrelated-dir" && cd "$RUNNER_TEMP/unrelated-dir" + set +e + OUT=$(printf '%s\n' '[["goto","http://127.0.0.1:47124/"],["text"]]' | "$BROWSE" chain 2>&1) + CODE=$? + set -e + echo "$OUT" + if [ "$CODE" -ne 0 ]; then + find "$RUNNER_TEMP/unrelated-dir" "$SMOKE_HOME" -maxdepth 4 \( -name 'browse-daemon.log' -o -name 'browse-startup-error.log' \) -print -exec tail -n 60 {} \; 2>/dev/null || true + echo "browse chain exited $CODE" && exit 1 + fi + grep -q 'gstack-windows-smoke-ok' <<< "$OUT" || (echo "chain output lacks the page text: the flow did not run" && exit 1) + shell: bash + + - name: Fastest Windows-safe free tests for install and the core skills + # Windows-measured seconds from scripts/free-test-durations-windows.json. + # setup-windows-fallback (0.1): setup's _link_or_copy copies, never links, on Windows + # setup-staged-copy (0.6): a copy install is staged and swapped in only when complete + # setup-windows-rerun-refresh (1.7): re-running ./setup refreshes copied skills (#2444) + # setup-skill-copies (0.1): copied SKILL.md hashes and the backup of an edited copy + # setup-bun-cmd-and-pipe-bugs (0.4): setup's bun.cmd and pipe handling + # gstack-launch-probe (0.1): setup's Smart App Control launch probe (#2595) + # state-root-parity (2.1): every skill start resolves one state root in TS and bash + # bun-isolation-launch (1.1): Bun entries ignore .env and bunfig under Git Bash (#3415) + # gstack-lease-git-bash (3.7): the skill-start lease under Git Bash (#3330) + # gstack-wtree-windows-paths (1.3): /review and /ship record a start on C:/ paths (#3101) + # ship-hook-windows-paths (2.3): /ship's pre-push guard in a Windows worktree (#2805) + # qa-only-browser-probe (2.4): /qa-only's browser probe fixture + # find-browse (0.1): /browse resolves its binary (.exe) + # cli-chain-stdin (0.3): /browse chain reads its flow from stdin on Windows (#3039) + # windows-spawn-hide (0.1): /browse's spawns hide their console window (#1835) + env: + GSTACK_FREE_RETRY_FLAKY: '1' + GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} + run: | + set -euo pipefail + cat > "$RUNNER_TEMP/windows-smoke-files.txt" <<'LIST' + test/setup-windows-fallback.test.ts + test/setup-staged-copy.test.ts + test/setup-windows-rerun-refresh.test.ts + test/setup-skill-copies.test.ts + test/setup-bun-cmd-and-pipe-bugs.test.ts + test/gstack-launch-probe.test.ts + test/state-root-parity.test.ts + test/bun-isolation-launch.test.ts + test/gstack-lease-git-bash.test.ts + test/gstack-wtree-windows-paths.test.ts + test/ship-hook-windows-paths.test.ts + test/qa-only-browser-probe.test.ts + browse/test/find-browse.test.ts + browse/test/cli-chain-stdin.test.ts + browse/test/windows-spawn-hide.test.ts + LIST + bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt" + shell: bash + + - name: Upload full test logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: windows-smoke-logs + path: | + .context/free-test-logs/gstack-free-test-*.log + ${{ runner.temp }}/gstack-free-test-*.log + include-hidden-files: true + if-no-files-found: ignore + + - name: Upload flake ledger + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: flake-ledger-windows-smoke + path: ${{ runner.temp }}/flake-ledger.jsonl + if-no-files-found: ignore + retention-days: 90 diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 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@'));