From 3176a7c2d80a5dacd085cb658874c37b031aff66 Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 12:15:01 -0400 Subject: [PATCH] ci(keeper): add blocking keeper test and typecheck runner and CI verification Fixes #516 --- docs/CI.md | 9 +++++ package.json | 5 +-- scripts/keeper-ci.test.mjs | 57 ++++++++++++++++++++++++++++++++ scripts/run-keeper-ci.mjs | 68 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 scripts/keeper-ci.test.mjs create mode 100644 scripts/run-keeper-ci.mjs diff --git a/docs/CI.md b/docs/CI.md index 4a3af75..d59e48b 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,3 +83,12 @@ print order are asserted by: ```bash node --test scripts/check-bindings-cli.test.mjs ``` + +## Keeper checks + +Run the blocking test and typecheck suite for Keeper locally and in CI: + +```bash +pnpm keeper:ci +node --test scripts/keeper-ci.test.mjs +``` diff --git a/package.json b/package.json index 66e5b7c..bd47efa 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "errors:check": "node scripts/check-round-errors.mjs", "errors:test": "node --test scripts/check-round-errors.test.mjs", "bindings:generate": "stellar contract build --out-dir artifacts && rm -rf tmp-bindings && stellar contract bindings typescript --wasm artifacts/sub_rosa_round.wasm --output-dir tmp-bindings --overwrite && cp tmp-bindings/src/index.ts packages/round-bindings/src/index.ts && rm -rf tmp-bindings", - "bindings:check": "stellar contract build --out-dir artifacts && rm -rf tmp-bindings && stellar contract bindings typescript --wasm artifacts/sub_rosa_round.wasm --output-dir tmp-bindings --overwrite && (diff tmp-bindings/src/index.ts packages/round-bindings/src/index.ts; diff_exit=$?; rm -rf tmp-bindings; if [ $diff_exit -ne 0 ]; then echo '❌ Round bindings are stale. Run pnpm bindings:generate to update them.'; exit 1; else echo '✅ Round bindings are up to date.'; fi)", + "bindings:check": "stellar contract build --out-dir artifacts && rm -rf tmp-bindings && stellar contract bindings typescript --wasm artifacts/sub_rosa_round.wasm --output-dir tmp-bindings --overwrite && (diff tmp-bindings/src/index.ts packages/round-bindings/src/index.ts; diff_exit=$?; rm -rf tmp-bindings; if [ $diff_exit -ne 0 ]; then echo '\u274c Round bindings are stale. Run pnpm bindings:generate to update them.'; exit 1; else echo '\u2705 Round bindings are up to date.'; fi)", "sdk:test": "pnpm --filter @sub-rosa/sdk test", "sdk:typecheck": "pnpm --filter @sub-rosa/sdk typecheck", "bindings:test": "pnpm --filter @sub-rosa/round-bindings test", @@ -87,6 +87,7 @@ "errors:normalize:check": "node scripts/check-error-normalization.mjs", "errors:normalize:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-error-normalization.test.mjs", "line-lengths:check": "node scripts/check-line-lengths.mjs", - "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs" + "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs", + "keeper:ci": "node --test scripts/keeper-ci.test.mjs && pnpm keeper:test && pnpm keeper:typecheck" } } diff --git a/scripts/keeper-ci.test.mjs b/scripts/keeper-ci.test.mjs new file mode 100644 index 0000000..0274300 --- /dev/null +++ b/scripts/keeper-ci.test.mjs @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { + CI_STEPS, + TARGET_WORKSPACES, + runKeeperCi, + verifyKeeperCiConfig, +} from "./run-keeper-ci.mjs"; + +const rootPkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); + +test("Keeper CI configuration defines blocking test and typecheck steps in order", () => { + const verified = verifyKeeperCiConfig(); + assert.deepEqual(verified.steps, ["keeper:test", "keeper:typecheck"]); + assert.ok(TARGET_WORKSPACES.length >= 1); + + assert.ok( + rootPkg.scripts["keeper:ci"], + "root script keeper:ci must exist", + ); + + let previousIndex = -1; + for (const step of CI_STEPS) { + assert.ok(rootPkg.scripts[step], `root script ${step} must exist`); + const idx = rootPkg.scripts["keeper:ci"].indexOf(`pnpm ${step}`); + assert.ok( + idx > previousIndex, + `${step} must run as a blocking step in keeper:ci`, + ); + previousIndex = idx; + } +}); + +test("Keeper CI runner executes each step sequentially and blocks on failure", () => { + const executed = []; + runKeeperCi(undefined, (_bin, args) => { + executed.push(args.join(" ")); + }); + assert.deepEqual( + executed, + CI_STEPS.map((step) => `run ${step}`), + ); + + assert.throws( + () => + runKeeperCi(undefined, (_bin, args) => { + if (args[1] === CI_STEPS[0]) { + throw new Error("simulated step failure"); + } + }), + /Blocking CI step failed/, + ); +}); diff --git a/scripts/run-keeper-ci.mjs b/scripts/run-keeper-ci.mjs new file mode 100644 index 0000000..4245695 --- /dev/null +++ b/scripts/run-keeper-ci.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Sub Rosa contributors +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createLogger } from "../packages/logging/src/index.cjs"; +import { normalizeError } from "../packages/logging/src/errors.cjs"; + +const diagnostics = createLogger("scripts.run-keeper-ci"); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); + +export const CI_STEPS = Object.freeze(["keeper:test", "keeper:typecheck"]); +export const TARGET_WORKSPACES = Object.freeze([{"path": "services/keeper", "name": "@sub-rosa/keeper"}]); + +export function verifyKeeperCiConfig(root = ROOT) { + const rootPkgPath = resolve(root, "package.json"); + const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); + const scripts = rootPkg.scripts ?? {}; + + for (const step of CI_STEPS) { + if (!scripts[step]) { + throw new Error(`Missing required root script: ${step}`); + } + } + + for (const ws of TARGET_WORKSPACES) { + const wsPkgPath = resolve(root, ws.path, "package.json"); + if (!existsSync(wsPkgPath)) { + throw new Error(`Missing workspace package.json: ${ws.path}`); + } + const wsPkg = JSON.parse(readFileSync(wsPkgPath, "utf8")); + if (wsPkg.name !== ws.name) { + throw new Error(`Unexpected package name in ${ws.path}: ${wsPkg.name}`); + } + if (!wsPkg.scripts?.test) { + throw new Error(`Missing test script in ${ws.path}`); + } + if (!wsPkg.scripts?.typecheck) { + throw new Error(`Missing typecheck script in ${ws.path}`); + } + } + + return { steps: CI_STEPS, workspaces: TARGET_WORKSPACES }; +} + +export function runKeeperCi(root = ROOT, runner = execFileSync) { + verifyKeeperCiConfig(root); + const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + for (const step of CI_STEPS) { + diagnostics.info("ci-step-start", `Running pnpm ${step}`); + try { + runner(pnpmBin, ["run", step], { + cwd: root, + stdio: "inherit", + env: { ...process.env }, + }); + } catch (error) { + diagnostics.error("ci-step-failed", normalizeError(error)); + throw new Error(`Blocking CI step failed: ${step}`); + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runKeeperCi(); +}