Commit6930df0
fix(gate): a GateTest outage must not hard-block every merge platform-wide
fix(gate): a GateTest outage must not hard-block every merge platform-wide Root cause of the 2026-07-15 incident: gatetest.ai (an external third-party scanner) returned 503 (missing GLUECRON_EMITTER_SECRET on its own deployment), and runGateTestScan treated that as a scan failure — indistinguishable from the code actually failing a security scan. Every merge on every repo with GateTest required failed with "GateTest returned 503: ...". runGateTestScan now treats non-2xx responses and network errors/timeouts as skip-not-block (matching the existing not-configured path), while a genuine 200 response with an explicit pass/fail verdict still blocks as before. A third-party outage should never gate this platform's core ability to merge. Also flips the default for gate_test_enabled on newly-created repos to false (migration 0112) — existing repos are unaffected, and owners can still opt in per-repo. GateTest hasn't proven reliable enough to be a required-by-default external dependency yet. Fixed a doctor.ts test-fixture bug found in the same pass: gluecron_list_tree was missing its required `ref` arg, causing a false FAIL against a working tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5 files changed+114−66930df0e074b111ffbdcfc900b4f3dbcb777eae3
5 changed files+114−6
Addeddrizzle/0112_gatetest_default_off.sql+9−0View fileUnifiedSplit
@@ -0,0 +1,9 @@
1-- GateTest is an external third-party scanner. An outage on its side
2-- (2026-07-15, gatetest.ai missing GLUECRON_EMITTER_SECRET) hard-blocked
3-- every merge on every repo that required it. gate.ts now treats a GateTest
4-- outage as skip-not-block regardless of this flag, but new repos shouldn't
5-- opt into requiring an unproven external dependency by default.
6-- Existing repos keep their current setting — this only changes the
7-- default applied to newly-inserted repo_settings rows.
8ALTER TABLE repo_settings
9 ALTER COLUMN gate_test_enabled SET DEFAULT false;
Modifiedscripts/doctor.ts+1−1View fileUnifiedSplit
@@ -201,7 +201,7 @@ const READ_MATRIX: Record<string, Record<string, unknown>> = {
201201 gluecron_search_prs: { owner: "ccantynz", repo: "Gluecron.com", query: "fix" },
202202 gluecron_repo_list_issues: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
203203 gluecron_search_issues: { owner: "ccantynz", repo: "Gluecron.com", query: "deploy" },
204 gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", path: "" },
204 gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", ref: "main", path: "" },
205205 gluecron_repo_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
206206 gluecron_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
207207 gluecron_clone_url: { owner: "ccantynz", repo: "Gluecron.com" },
Addedsrc/__tests__/gate-gatetest-availability.test.ts+84−0View fileUnifiedSplit
@@ -0,0 +1,84 @@
1/**
2 * runGateTestScan availability handling.
3 *
4 * A GateTest outage or misconfiguration (non-2xx response, network error)
5 * must not hard-block merges platform-wide — that makes every merge
6 * hostage to a third-party service's uptime. Only an actual scan verdict
7 * (a 200 response with a pass/fail result) should block. This mirrors the
8 * "not configured" path, which already skips rather than fails.
9 *
10 * Regression coverage for the 2026-07-15 incident: gatetest.ai returned
11 * 503 (missing GLUECRON_EMITTER_SECRET on its side) and every merge on
12 * every repo failed with "GateTest returned 503: ...".
13 */
14
15import { afterEach, beforeEach, describe, expect, it } from "bun:test";
16import { runGateTestScan } from "../lib/gate";
17import { config } from "../lib/config";
18
19const origFetch = globalThis.fetch;
20const origUrl = process.env.GATETEST_URL;
21
22function mockFetch(responder: () => Response | Promise<Response>): void {
23 // @ts-expect-error — override global fetch for the test
24 globalThis.fetch = async (): Promise<Response> => responder();
25}
26
27beforeEach(() => {
28 process.env.GATETEST_URL = "https://gatetest.example/api/events/push";
29});
30
31afterEach(() => {
32 globalThis.fetch = origFetch;
33 if (origUrl === undefined) delete process.env.GATETEST_URL;
34 else process.env.GATETEST_URL = origUrl;
35});
36
37describe("runGateTestScan — availability vs verdict", () => {
38 it("skips (does not block) when GateTest is not configured", async () => {
39 delete process.env.GATETEST_URL;
40 const result = await runGateTestScan("o", "r", "refs/heads/main", "a".repeat(40));
41 expect(result.skipped).toBe(true);
42 expect(result.passed).toBe(true);
43 });
44
45 it("skips (does not block) on a non-2xx response — e.g. GateTest itself is down", async () => {
46 mockFetch(() => new Response(JSON.stringify({ error: "GLUECRON_EMITTER_SECRET is not set" }), { status: 503 }));
47 const result = await runGateTestScan("o", "r", "refs/heads/main", "a".repeat(40));
48 expect(result.skipped).toBe(true);
49 expect(result.passed).toBe(true);
50 expect(result.details).toContain("503");
51 });
52
53 it("skips (does not block) when the request throws — network error / timeout", async () => {
54 mockFetch(() => {
55 throw new Error("ECONNREFUSED");
56 });
57 const result = await runGateTestScan("o", "r", "refs/heads/main", "a".repeat(40));
58 expect(result.skipped).toBe(true);
59 expect(result.passed).toBe(true);
60 expect(result.details).toContain("ECONNREFUSED");
61 });
62
63 it("blocks on a genuine 200 scan-fail verdict", async () => {
64 mockFetch(() => new Response(JSON.stringify({ passed: false, summary: "2 vulnerabilities found" }), { status: 200 }));
65 const result = await runGateTestScan("o", "r", "refs/heads/main", "a".repeat(40));
66 expect(result.skipped).toBeFalsy();
67 expect(result.passed).toBe(false);
68 expect(result.details).toBe("2 vulnerabilities found");
69 });
70
71 it("passes on a genuine 200 scan-pass verdict", async () => {
72 mockFetch(() => new Response(JSON.stringify({ passed: true, summary: "All checks passed" }), { status: 200 }));
73 const result = await runGateTestScan("o", "r", "refs/heads/main", "a".repeat(40));
74 expect(result.skipped).toBeFalsy();
75 expect(result.passed).toBe(true);
76 });
77});
78
79// Keep config import referenced — sanity that config.gatetestUrl reflects env for this test file.
80describe("config.gatetestUrl reflects GATETEST_URL for this suite", () => {
81 it("resolves to the mocked value while set", () => {
82 expect(config.gatetestUrl).toBe("https://gatetest.example/api/events/push");
83 });
84});
Modifiedsrc/db/schema.ts+8−1View fileUnifiedSplit
@@ -268,7 +268,14 @@ export const repoSettings = pgTable("repo_settings", {
268268 .unique()
269269 .references(() => repositories.id, { onDelete: "cascade" }),
270270 // Gates
271 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
271 // gateTestEnabled defaults to false (flipped 2026-07-15, migration 0112):
272 // GateTest is an external third-party scanner and an outage on its side
273 // was hard-blocking every merge platform-wide. gate.ts now treats a
274 // GateTest outage as skip-not-block regardless of this flag, but new
275 // repos shouldn't opt into requiring an unproven external dependency by
276 // default. Existing repos are unaffected — this only changes the default
277 // for newly-inserted repo_settings rows. Owners can still opt in per-repo.
278 gateTestEnabled: boolean("gate_test_enabled").default(false).notNull(),
272279 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
273280 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
274281 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
Modifiedsrc/lib/gate.ts+12−4View fileUnifiedSplit
@@ -202,11 +202,17 @@ export async function runGateTestScan(
202202 });
203203
204204 if (!response.ok) {
205 // A non-2xx here means GateTest itself is unreachable/broken (5xx,
206 // auth misconfig, etc) — that is an availability failure of a
207 // third-party dependency, not a scan verdict on the code. Treat it
208 // like "not configured": skip (don't block merges platform-wide on
209 // an external outage) but keep the detail visible so it's not silent.
205210 const body = await response.text().catch(() => "");
206211 return {
207212 name: "GateTest",
208 passed: false,
209 details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`,
213 passed: true,
214 skipped: true,
215 details: `GateTest unavailable (HTTP ${response.status}) — scan skipped, not blocking: ${body.slice(0, 200)}`,
210216 };
211217 }
212218
@@ -217,11 +223,13 @@ export async function runGateTestScan(
217223 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
218224 return { name: "GateTest", passed, details: summary };
219225 } catch (err) {
226 // Network error / timeout — same reasoning as the non-2xx branch above.
220227 console.error("[gate] GateTest scan error:", err);
221228 return {
222229 name: "GateTest",
223 passed: false,
224 details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`,
230 passed: true,
231 skipped: true,
232 details: `GateTest unreachable — scan skipped, not blocking: ${err instanceof Error ? err.message : "Unknown error"}`,
225233 };
226234 }
227235}
228236