diff --git a/.github/workflows/full-suite.yml b/.github/workflows/full-suite.yml index a207d4e0bc..286674c0d9 100644 --- a/.github/workflows/full-suite.yml +++ b/.github/workflows/full-suite.yml @@ -12,9 +12,13 @@ on: push: branches: [main] +# Key the concurrency group by SHA, not ref: on push to main the ref is +# always refs/heads/main, so a ref-keyed group with cancel-in-progress would +# let consecutive merges cancel each other's runs — silently skipping the +# only coverage for everything the gate dropped. Per-SHA groups never collide. concurrency: - group: full-suite-${{ github.ref }} - cancel-in-progress: true + group: full-suite-${{ github.sha }} + cancel-in-progress: false # FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02. env: diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index fb8321ca39..1f4952c5fa 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -79,6 +79,10 @@ jobs: gate: name: Gate runs-on: ubuntu-latest + # The gate's value is speed; without a job timeout a hung build or + # deadlocked vitest worker blocks every PR for GitHub's default 6 hours. + # Expected runtime is ~3-5 min. + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/docs/testing.md b/docs/testing.md index 4c01d04ef9..e64608225f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -121,6 +121,8 @@ Flaky tests are quarantined ON SIGHT and deleted on a 2-week clock. This is writ **Gate eviction:** a flake inside the merge gate cannot block all merges while red — it is evicted by removing its line from the `engine-core` allow-list (no quarantine entry needed unless it should also leave the non-blocking tier). +**Gate admission:** the mirror operation — add the test's path to the `engine-core` `include` array in `packages/engine/vitest.config.ts`, citing the evidence of value (a real regression it caught) in the PR. Keep the project under its ~60s wall-clock budget. + **Product-race escalation:** a second quarantine in the same subsystem is a smell that the flake is a real product race, not test noise — look at the product code before deleting (a dashboard flake was "stabilized" three times before being found to be a real race; see `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`). ## CI shard balancing (duration-weighted) diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 0f4fa23b6b..fd86cbdeeb 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -124,7 +124,7 @@ test("shouldForceFullSuite: returns true when scripts/check-test-isolation.mjs c }); test("shouldForceFullSuite: returns true when a GitHub workflow changed", () => { - assert.equal(shouldForceFullSuite([".github/workflows/ci.yml"]), true); + assert.equal(shouldForceFullSuite([".github/workflows/pr-checks.yml"]), true); }); test("shouldForceFullSuite: returns false for .changeset/*.md summary files", () => { diff --git a/scripts/boot-smoke.mjs b/scripts/boot-smoke.mjs index 2143641776..79940c9229 100644 --- a/scripts/boot-smoke.mjs +++ b/scripts/boot-smoke.mjs @@ -12,6 +12,11 @@ * (port-4040-allowlist: this file only ever AVOIDS the reserved ports — it * requests an ephemeral port and rejects reserved ones; it never binds, * probes, or kills them.) + * (process-supervisor-allowlist: raw spawn is intentional here — this is a + * standalone repo script outside the package graph, the child is attached + * (not detached), and lifecycle is bounded by the timeouts + signal handlers + * below; importing superviseSpawn from @fusion/core would invert the + * dependency direction for a build-time smoke check.) * - Never binds or touches port 4040 / FUSION_RESERVED_PORTS — an ephemeral * port is requested from the OS (listen on 0) and double-checked against * the reserved list. @@ -128,10 +133,26 @@ async function main() { child.stdout.on("data", (d) => (stderrBuf += d)); const cleanup = () => { - if (child.exitCode === null && !child.killed) child.kill("SIGTERM"); + // 'exit' handlers cannot await: escalate straight to SIGKILL so a child + // that ignores SIGTERM is never orphaned holding the port/tmpdir. The + // graceful SIGTERM path below runs before this on the success path. + try { + if (child.exitCode === null && !child.killed) child.kill("SIGKILL"); + } catch { + // ESRCH: child already reaped between the check and the kill — fine. + } rmSync(isolatedHome, { recursive: true, force: true }); }; process.on("exit", cleanup); + // Node does NOT fire 'exit' on signals by default. A cancelled CI job + // (timeout, manual cancel, runner eviction) sends SIGTERM — without these + // handlers the serve child would be orphaned. + for (const sig of ["SIGTERM", "SIGINT"]) { + process.on(sig, () => { + cleanup(); + process.exit(sig === "SIGINT" ? 130 : 143); + }); + } const exitedEarly = new Promise((resolve) => { child.once("exit", (code, signal) => resolve({ code, signal })); @@ -150,7 +171,12 @@ async function main() { console.log(`boot-smoke: GET /api/health 200 on :${port}`); // 3. Clean shutdown of OUR child only. - child.kill("SIGTERM"); + try { + child.kill("SIGTERM"); + } catch { + // ESRCH: server exited between the health check and the kill — the + // exitedEarly promise below already carries its exit code. + } const { code, signal } = await Promise.race([ exitedEarly, new Promise((resolve) => diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 461de987bb..78567fdf72 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1158,6 +1158,12 @@ export function main(argv = process.argv.slice(2)) { // Cache-fresh fast path: nothing to run. Emit a fast-path mode line, run only // the (now cheap) isolation guard, and skip skill-sync, artifact-ensure, // HOME creation, and prune. + // + // NOTE: this path is reachable only in CHANGED mode (gate mode sets hasWork + // above), and it intentionally skips the merge-gate suite too: an all-cache- + // fresh changed run means the engine/cli content feeding the gate suite is + // byte-identical to a previously green run. Any shared-infra change that + // could invalidate that reasoning routes to gate mode instead of here. if (!hasWork) { console.log("[test-changed] fast-path=cache-fresh (no packages to run)."); console.log(