Public: anyone can read this; the crew can change it.
require_checks, red half: a failing head refuses the merge and its preview still answers, every time #5291
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5277-require-checks-red-half-a-failing-head-r → main 1 commit fork
2 files
changed +116
−16
test/apps-require-checks-red.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * `require_checks`, the red half (docs/APPS.md): a pull whose head's tests | |
| 3 | * fail has a settled `failed` deploy, the merge is refused, and the preview | |
| 4 | * still answers with its own page. Waits on the deploy of that exact head | |
| 5 | * (`commitOid`), never on "the latest". The green half is its own file. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { newApp } from "../src/actions/apps.ts"; | |
| 10 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { commitToBranch, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 12 | import { getPull } from "../src/core/pulls.ts"; | |
| 13 | import { getRepoById } from "../src/core/repos.ts"; | |
| 14 | import { | |
| 15 | APPS_DOMAIN, | |
| 16 | deploySettled, | |
| 17 | fetchAsHost, | |
| 18 | startAppsForge, | |
| 19 | stopAppsForge, | |
| 20 | type AppsForge, | |
| 21 | } from "./helpers/apps-forge.ts"; | |
| 22 | ||
| 23 | describe("require_checks: a failing head", () => { | |
| 24 | let forge: AppsForge; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | forge = await startAppsForge(); | |
| 28 | }, 60_000); | |
| 29 | ||
| 30 | afterAll(async () => { | |
| 31 | await forge.runtime.stop(); | |
| 32 | await stopAppsForge(forge); | |
| 33 | }); | |
| 34 | ||
| 35 | test("refuses the merge and its preview still answers", async () => { | |
| 36 | const { app, alice, servers, runtime } = forge; | |
| 37 | const made = await newApp(app, alice, { | |
| 38 | name: "hello", | |
| 39 | description: "A place for notes", | |
| 40 | visibility: "public", | |
| 41 | }); | |
| 42 | expect(made.ok).toBe(true); | |
| 43 | if (!made.ok) return; | |
| 44 | const record = made.value.app; | |
| 45 | let repo = made.value.repo; | |
| 46 | const production = await deploySettled( | |
| 47 | app, | |
| 48 | repo.id, | |
| 49 | (r) => r.env === "production", | |
| 50 | ); | |
| 51 | expect(production.state).toBe("ok"); | |
| 52 | ||
| 53 | expect( | |
| 54 | (await updateRepoSettings(app, alice, repo, { requireChecks: true })).ok, | |
| 55 | ).toBe(true); | |
| 56 | repo = getRepoById(app.db, repo.id)!; | |
| 57 | const broken = await commitToBranch(app, alice, repo, { | |
| 58 | branch: "broken", | |
| 59 | base: repo.defaultBranch, | |
| 60 | message: "Break the test", | |
| 61 | files: [ | |
| 62 | { | |
| 63 | path: "src/server.test.ts", | |
| 64 | content: | |
| 65 | 'import { expect, test } from "bun:test";\ntest("fails", () => { expect(1).toBe(2); });\n', | |
| 66 | }, | |
| 67 | ], | |
| 68 | }); | |
| 69 | expect(broken.ok).toBe(true); | |
| 70 | if (!broken.ok) return; | |
| 71 | const opened = await openPull(app, alice, repo, { | |
| 72 | title: "Broken", | |
| 73 | baseBranch: repo.defaultBranch, | |
| 74 | headBranch: "broken", | |
| 75 | }); | |
| 76 | expect(opened.ok).toBe(true); | |
| 77 | if (!opened.ok) return; | |
| 78 | const number = opened.value.number; | |
| 79 | ||
| 80 | const failed = await deploySettled( | |
| 81 | app, | |
| 82 | repo.id, | |
| 83 | (r) => | |
| 84 | r.env === `pull-${number}` && r.commitOid === broken.value.commitOid, | |
| 85 | ); | |
| 86 | expect(failed.state).toBe("failed"); | |
| 87 | expect(failed.summary).toContain("test"); | |
| 88 | ||
| 89 | const refused = await mergePull( | |
| 90 | app, | |
| 91 | alice, | |
| 92 | repo, | |
| 93 | getPull(app.db, repo.id, number)!, | |
| 94 | "merge", | |
| 95 | ); | |
| 96 | expect(refused.ok).toBe(false); | |
| 97 | if (!refused.ok) expect(refused.error).toBe("checks_required"); | |
| 98 | ||
| 99 | // The app never broke, only its tests: the preview answers with its own | |
| 100 | // page rather than a gateway error. | |
| 101 | const page = await fetchAsHost(servers, `hello--${number}.${APPS_DOMAIN}`); | |
| 102 | expect(page.status).toBe(200); | |
| 103 | expect(await page.text()).toContain(`preview of pull #${number}`); | |
| 104 | const status = runtime.envStatus(record, `pull-${number}`); | |
| 105 | expect(status?.state).toBe("running"); | |
| 106 | expect(status?.error).toContain("tests failed"); | |
| 107 | }, 120_000); | |
| 108 | }); |
test/apps.test.ts +8 −16
| @@ -389,23 +389,15 @@ describe("apps: production, previews, data, gates", () => { | ||
| 389 | 389 | }); |
| 390 | 390 | expect(opened.ok).toBe(true); |
| 391 | 391 | if (!opened.ok) return; |
| 392 | const failed = await settled(latest(`pull-${opened.value.number}`)); | |
| 393 | expect(failed.state).toBe("failed"); | |
| 394 | expect(failed.summary).toContain("test"); | |
| 395 | const refused = await mergePull(app, alice, repo, opened.value, "merge"); | |
| 396 | expect(refused.ok).toBe(false); | |
| 397 | if (!refused.ok) expect(refused.error).toBe("checks_required"); | |
| 398 | // The app itself never broke, only its tests: the preview answers with | |
| 399 | // its own page rather than a gateway error, because a deployment that | |
| 400 | // answers is what was asked for. | |
| 401 | const page = await viaHost(`hello--${opened.value.number}.${DOMAIN}`); | |
| 402 | expect(page.status).toBe(200); | |
| 403 | expect(await page.text()).toContain( | |
| 404 | `preview of pull #${opened.value.number}`, | |
| 392 | // The red half (refused merge, preview still answering) is | |
| 393 | // apps-require-checks-red.test.ts; here the failed head only has to settle. | |
| 394 | if (!broken.ok) return; | |
| 395 | const failed = await settled( | |
| 396 | (r) => | |
| 397 | r.env === `pull-${opened.value.number}` && | |
| 398 | r.commitOid === broken.value.commitOid, | |
| 405 | 399 | ); |
| 406 | const status = runtime.envStatus(record, `pull-${opened.value.number}`); | |
| 407 | expect(status?.state).toBe("running"); | |
| 408 | expect(status?.error).toContain("tests failed"); | |
| 400 | expect(failed.state).toBe("failed"); | |
| 409 | 401 | |
| 410 | 402 | const fixed = await commitToBranch(app, alice, repo, { |
| 411 | 403 | branch: "broken", |
new above
offline