Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
This document describes automated checks for the web app and TypeScript
workspaces (`apps/*`, `packages/*`, and `services/*`).

## Root aggregate scripts

Run all maintained workspace unit tests or TypeScript typechecks from the
repository root in deterministic dependency order:

```bash
pnpm test # runs test across all 12 JS/TS workspaces
pnpm typecheck # runs typecheck across all 11 TypeScript workspaces
pnpm workspaces:check # verifies every workspace is included in the root lists
pnpm workspaces:test # runs unit tests for the aggregate runner
```

## Web checks

The `Web checks` workflow (`.github/workflows/web.yml`) runs on pushes to
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
}
},
"scripts": {
"test": "node scripts/run-workspace-checks.mjs test",
"typecheck": "node scripts/run-workspace-checks.mjs typecheck",
"workspaces:check": "node scripts/run-workspace-checks.mjs --check",
"workspaces:test": "node --test scripts/run-workspace-checks.test.mjs",
"env:check": "node scripts/check-env-examples.mjs",
"env:test": "node --test scripts/check-env-examples.test.mjs",
"logging:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-logging.test.mjs",
Expand Down Expand Up @@ -82,6 +86,7 @@
"receipt:test": "pnpm --filter @sub-rosa/receipt-cli test",
"receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck",
"time:test": "pnpm --filter @sub-rosa/time test",
"time:typecheck": "pnpm --filter @sub-rosa/time typecheck",
"time:guard": "node scripts/check-direct-time-access.mjs",
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs",
"errors:normalize:check": "node scripts/check-error-normalization.mjs",
Expand Down
162 changes: 162 additions & 0 deletions scripts/run-workspace-checks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env node
// Copyright (c) 2026 Sub Rosa contributors
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT = resolve(__dirname, "..");

/**
* Deterministic ordered list of maintained workspaces that expose a `test` script.
*/
export const TEST_WORKSPACES = [
"@sub-rosa/logging",
"@sub-rosa/time",
"@sub-rosa/round-bindings",
"@sub-rosa/tlock",
"@sub-rosa/sdk",
"@sub-rosa/drand-tools",
"@sub-rosa/keeper",
"@sub-rosa/receipt-cli",
"@sub-rosa/appraisal-api",
"@sub-rosa/agent",
"@sub-rosa/auction-template",
"@sub-rosa/web",
];

/**
* Deterministic ordered list of maintained TypeScript workspaces that expose a `typecheck` script.
*/
export const TYPECHECK_WORKSPACES = [
"@sub-rosa/time",
"@sub-rosa/round-bindings",
"@sub-rosa/tlock",
"@sub-rosa/sdk",
"@sub-rosa/drand-tools",
"@sub-rosa/keeper",
"@sub-rosa/receipt-cli",
"@sub-rosa/appraisal-api",
"@sub-rosa/agent",
"@sub-rosa/auction-template",
"@sub-rosa/web",
];

const WORKSPACE_CONTAINERS = ["packages", "services", "apps"];

export function discoverWorkspacesWithScript(scriptName, rootDir = DEFAULT_ROOT) {
const found = [];
for (const container of WORKSPACE_CONTAINERS) {
const absContainer = join(rootDir, container);
if (!existsSync(absContainer)) continue;
const entries = readdirSync(absContainer, { withFileTypes: true }).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const pkgPath = join(absContainer, entry.name, "package.json");
if (!existsSync(pkgPath)) continue;
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
if (pkg.name && pkg.scripts?.[scriptName]) {
found.push(pkg.name);
}
}
}
return found;
}

export function verifyAggregateCoverage(
rootDir = DEFAULT_ROOT,
{
testWorkspaces = TEST_WORKSPACES,
typecheckWorkspaces = TYPECHECK_WORKSPACES,
} = {},
) {
const issues = [];
const actualTests = discoverWorkspacesWithScript("test", rootDir);
const actualTypechecks = discoverWorkspacesWithScript("typecheck", rootDir);

for (const ws of actualTests) {
if (!testWorkspaces.includes(ws)) {
issues.push(`Workspace ${ws} has a "test" script but is missing from TEST_WORKSPACES`);
}
}
for (const ws of testWorkspaces) {
if (!actualTests.includes(ws)) {
issues.push(`TEST_WORKSPACES lists ${ws}, which does not define a "test" script`);
}
}

for (const ws of actualTypechecks) {
if (!typecheckWorkspaces.includes(ws)) {
issues.push(
`Workspace ${ws} has a "typecheck" script but is missing from TYPECHECK_WORKSPACES`,
);
}
}
for (const ws of typecheckWorkspaces) {
if (!actualTypechecks.includes(ws)) {
issues.push(
`TYPECHECK_WORKSPACES lists ${ws}, which does not define a "typecheck" script`,
);
}
}

const rootPkgPath = join(rootDir, "package.json");
if (existsSync(rootPkgPath)) {
const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8"));
if (!rootPkg.scripts?.test) {
issues.push('Root package.json is missing aggregate "test" script');
}
if (!rootPkg.scripts?.typecheck) {
issues.push('Root package.json is missing aggregate "typecheck" script');
}
} else {
issues.push("Missing root package.json");
}

return { ok: issues.length === 0, issues };
}

function main() {
const args = process.argv.slice(2);
if (args.includes("--check")) {
const res = verifyAggregateCoverage(DEFAULT_ROOT);
if (!res.ok) {
for (const issue of res.issues) {
process.stderr.write(`❌ ${issue}\n`);
}
process.exit(1);
}
process.stdout.write(
`✅ Root aggregate test (${TEST_WORKSPACES.length}) and typecheck (${TYPECHECK_WORKSPACES.length}) workspace lists are complete.\n`,
);
return;
}

const mode = args[0];
if (mode !== "test" && mode !== "typecheck") {
process.stderr.write(
"Usage: node scripts/run-workspace-checks.mjs <test|typecheck|--check>\n",
);
process.exit(1);
}

const list = mode === "test" ? TEST_WORKSPACES : TYPECHECK_WORKSPACES;
for (const ws of list) {
process.stdout.write(`\n▶ pnpm --filter ${ws} ${mode}\n`);
const res = spawnSync("pnpm", ["--filter", ws, mode], {
cwd: DEFAULT_ROOT,
stdio: "inherit",
shell: process.platform === "win32",
});
if ((res.status ?? 1) !== 0) {
process.exit(res.status ?? 1);
}
}
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main();
}
71 changes: 71 additions & 0 deletions scripts/run-workspace-checks.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Sub Rosa contributors
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";

import {
TEST_WORKSPACES,
TYPECHECK_WORKSPACES,
verifyAggregateCoverage,
} from "./run-workspace-checks.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..");

describe("root test and typecheck aggregate scripts", () => {
it("defines root test, typecheck, and time:typecheck scripts in package.json", () => {
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8"));
assert.equal(pkg.scripts.test, "node scripts/run-workspace-checks.mjs test");
assert.equal(
pkg.scripts.typecheck,
"node scripts/run-workspace-checks.mjs typecheck",
);
assert.equal(
pkg.scripts["time:typecheck"],
"pnpm --filter @sub-rosa/time typecheck",
);
});

it("enumerates every maintained workspace with test and typecheck scripts", () => {
const res = verifyAggregateCoverage(REPO_ROOT);
assert.equal(res.ok, true, `Unexpected issues: ${res.issues.join("; ")}`);
assert.equal(TEST_WORKSPACES.length, 12);
assert.equal(TYPECHECK_WORKSPACES.length, 11);
});

it("fails when a workspace defines test or typecheck but is omitted from the aggregate list", () => {
const tmp = mkdtempSync(join(tmpdir(), "sub-rosa-agg-"));
try {
mkdirSync(join(tmp, "packages", "alpha"), { recursive: true });
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({
scripts: {
test: "node scripts/run-workspace-checks.mjs test",
typecheck: "node scripts/run-workspace-checks.mjs typecheck",
},
}),
);
writeFileSync(
join(tmp, "packages", "alpha", "package.json"),
JSON.stringify({
name: "@sub-rosa/alpha",
scripts: { test: "node --test", typecheck: "tsc --noEmit" },
}),
);

const res = verifyAggregateCoverage(tmp, {
testWorkspaces: [],
typecheckWorkspaces: [],
});
assert.equal(res.ok, false);
assert.equal(res.issues.length, 2);
assert.match(res.issues[0], /@sub-rosa\/alpha/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});