From 195bf0b3fc19bbfb19853e354a2dde327bcddea9 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:49:22 +0000 Subject: [PATCH] fix: pre-landing review fixes (dispatch-ci skips a workflow the branch predates; harness decision; changelog fragment) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gsta203-pr1-windows.md | 13 +++++++++++++ docs/ship-policy.md | 16 +++++++++------- lib/ship-queue-dispatch-ci.ts | 11 ++++++++++- scripts/harness-version.json | 2 +- test/ship-queue-train-ops.test.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 changelog.d/gsta203-pr1-windows.md diff --git a/changelog.d/gsta203-pr1-windows.md b/changelog.d/gsta203-pr1-windows.md new file mode 100644 index 000000000..3afc144b3 --- /dev/null +++ b/changelog.d/gsta203-pr1-windows.md @@ -0,0 +1,13 @@ +**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: it only lists `windows-smoke.yml` among files no paid case reads. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 91c10f4aa..268b21d4d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -213,13 +213,15 @@ has no merge ref for the PR, so no `pull_request` CI runs on the head and 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/scripts/harness-version.json b/scripts/harness-version.json index 420cf7e06..8e4ef1fb3 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/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', () => {