diff --git a/scripts/ci-main-tree-proof.mjs b/scripts/ci-main-tree-proof.mjs index 397c8958e8..e6533d8011 100644 --- a/scripts/ci-main-tree-proof.mjs +++ b/scripts/ci-main-tree-proof.mjs @@ -494,6 +494,7 @@ function assert(condition, message) { } async function selfTest() { + if (process.env.CI_TREE_PROOF_SELF_TEST_FORCE_FAIL === "1") assert(false, "forced failure (test hook)"); const ok = (name, extra = {}) => ({ name, status: "completed", conclusion: "success", ...extra }); const full = [ { ...ok("Change scope"), id: 501 }, @@ -775,7 +776,17 @@ function recordTestedTree({ env = process.env, runGit = git } = {}) { } async function main() { - if (process.argv.includes("--self-test")) return selfTest(); + if (process.argv.includes("--self-test")) { + // A self-test failure must fail the process. The fail-open handler below exists for the CI + // proof path only; applied here it would print a warning and exit 0, hiding broken guards. + try { + await selfTest(); + } catch (error) { + console.error(`ci-main-tree-proof self-test FAILED: ${String(error?.stack ?? error)}`); + process.exitCode = 1; + } + return; + } if (process.argv.includes("--record-tested-tree")) { try { recordTestedTree(); diff --git a/tests/ci-main-tree-proof.test.ts b/tests/ci-main-tree-proof.test.ts index 8c82ebe49a..9554e74b5b 100644 --- a/tests/ci-main-tree-proof.test.ts +++ b/tests/ci-main-tree-proof.test.ts @@ -48,6 +48,34 @@ describe("main tree proof script", () => { expect(result.stdout).toContain("ci-main-tree-proof self-test passed."); }); + it("exits non-zero when a self-test assertion fails, writing no GitHub outputs", () => { + const outDir = mkdtempSync(path.join(tmpdir(), "tree-proof-self-test-")); + const outputFile = path.join(outDir, "output"); + const summaryFile = path.join(outDir, "summary"); + writeFileSync(outputFile, ""); + writeFileSync(summaryFile, ""); + try { + const result = spawnSync(process.execPath, ["scripts/ci-main-tree-proof.mjs", "--self-test"], { + encoding: "utf8", + env: { + ...process.env, + CI_TREE_PROOF_SELF_TEST_FORCE_FAIL: "1", + GITHUB_OUTPUT: outputFile, + GITHUB_STEP_SUMMARY: summaryFile, + }, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("self-test FAILED"); + expect(result.stderr).toMatch(/forced failure \(test hook\)[\r\n]\s+at /); + expect(result.stdout).not.toContain("self-test passed"); + expect(result.stdout).not.toContain("coverage="); + expect(readFileSync(outputFile, "utf8")).toBe(""); + expect(readFileSync(summaryFile, "utf8")).toBe(""); + } finally { + rmSync(outDir, { recursive: true, force: true, maxRetries: 5 }); + } + }); + it("names exactly the PR-run jobs each group skips, matching ci.yml job names", () => { const names = Object.values(jobs).map((job) => String((job as { name?: string }).name ?? "")); for (const name of [