diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 56b6f594c12..228ac3bd4b7 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -585,8 +585,16 @@ jobs: group_label: workspaces-a (2/2) shard_index: 1 shard_count: 2 + # workspaces-b became the slowest check (499-593s) once the server + # shards were rebalanced: the db project alone was 168-250s of its + # 436-513s of vitest time (PR runs 38084171761, 38083084276 and + # 38083020834, 2026-10-10). The db project now runs as its own + # workspaces-c lane; the rest of workspaces-b is ~265-275s of + # vitest time, so both jobs land near 300-350s with setup. - group: general-workspaces-b group_label: workspaces-b + - group: general-workspaces-c + group_label: workspaces-c steps: - name: Checkout repository diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 492b7e62b47..3fd321edb68 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -174,6 +174,9 @@ jobs: shard_count: 2 - group: general-workspaces-b group_label: workspaces-b + # Keep parity with pr.yml: the db project runs as its own lane. + - group: general-workspaces-c + group_label: workspaces-c steps: - name: Checkout repository diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 043636edb91..9d00a15b79e 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -505,7 +505,7 @@ test("release verify workflow covers the same split test surface as stable PR ve assert.match(buildJob, /persist-credentials: false/); assert.doesNotMatch(buildJob, /cache: pnpm/); - for (const group of ["general-server-without-chat-or-native-runner", "general-chat", "general-workspaces-a", "general-workspaces-b"]) { + for (const group of ["general-server-without-chat-or-native-runner", "general-chat", "general-workspaces-a", "general-workspaces-b", "general-workspaces-c"]) { assert.match(verifyWorkflow, new RegExp(`group: ${group}`)); } for (const [group, count] of [["general-server-without-chat-or-native-runner", 10], ["general-chat", 3]]) { diff --git a/scripts/__tests__/run-vitest-stable-shard.test.mjs b/scripts/__tests__/run-vitest-stable-shard.test.mjs index a044c67c151..3dd3e9680e0 100644 --- a/scripts/__tests__/run-vitest-stable-shard.test.mjs +++ b/scripts/__tests__/run-vitest-stable-shard.test.mjs @@ -107,10 +107,12 @@ test("general shards retain scripts and route-named suites outside the serialize ]) assert.ok(general.selectedGeneralServerSuites.includes(file), `missing configured server suite: ${file}`); }); -test("shard flags are rejected for the workspaces-b group", () => { - const result = dryRun(["--mode", "general", "--group", "general-workspaces-b", "--shard-index", "0", "--shard-count", "3"]); - assert.notEqual(result.status, 0, "workspaces-b must not accept shard flags"); -}); +for (const group of ["general-workspaces-b", "general-workspaces-c"]) { + test(`shard flags are rejected for the ${group} group`, () => { + const result = dryRun(["--mode", "general", "--group", group, "--shard-index", "0", "--shard-count", "3"]); + assert.notEqual(result.status, 0, `${group} must not accept shard flags`); + }); +} test("workspace lanes cover every non-server project in the root Vitest configuration", () => { const config = readFileSync(path.join(repoRoot, "vitest.config.ts"), "utf8"); @@ -118,11 +120,81 @@ test("workspace lanes cover every non-server project in the root Vitest configur assert.ok(roots.includes("server"), "expected the explicit root Vitest project list"); const expected = roots.filter(root => root !== "server") .map(root => JSON.parse(readFileSync(path.join(repoRoot, root, "package.json"), "utf8")).name).sort(); - const actual = ["general-workspaces-a", "general-workspaces-b"] + const actual = ["general-workspaces-a", "general-workspaces-b", "general-workspaces-c"] .flatMap(group => dryRunJson(["--mode", "general", "--group", group]).workspaceProjects).sort(); assert.deepEqual(actual, expected, "no configured project may be silently omitted or run twice"); }); +// Collects every `- group:` matrix entry in a workflow with the keys it sets. +// Comment and blank lines are dropped first, and an entry ends only when the +// indentation returns to the item level, so a comment between two keys cannot +// hide a shard setting from the assertions below. +function matrixEntries(text) { + const entries = []; + let current = null; + for (const line of text.split("\n")) { + if (/^\s*(#|$)/.test(line)) continue; + const indent = line.match(/^ */)[0].length; + const item = line.match(/^ *- group: (\S+)/); + if (item) { + current = { group: item[1], indent, keys: ["group"] }; + entries.push(current); + continue; + } + if (!current) continue; + if (indent <= current.indent) { current = null; continue; } + const key = line.match(/^ *([A-Za-z_]+):/); + if (key) current.keys.push(key[1]); + } + return entries; +} + +test("matrixEntries sees shard keys that sit behind comments or later in the entry", () => { + const entries = matrixEntries([ + " include:", + " - group: general-workspaces-b", + " group_label: workspaces-b", + " # a comment between keys must not hide the shard setting", + " shard_index: 0", + "", + " shard_count: 3", + " - group: general-workspaces-c", + " group_label: workspaces-c", + " steps:", + " - name: unrelated", + " shard_index: 9", + ].join("\n")); + assert.deepEqual(entries.map(entry => entry.group), ["general-workspaces-b", "general-workspaces-c"]); + assert.deepEqual(entries[0].keys, ["group", "group_label", "shard_index", "shard_count"]); + assert.deepEqual(entries[1].keys, ["group", "group_label"], "keys after the matrix must not leak into the last entry"); +}); + +test("workspaces-c runs the db project alone and workspaces-b no longer carries it", () => { + // The db project was 168-250s of the 436-513s workspaces-b vitest time + // (PR runs 38084171761, 38083084276 and 38083020834, 2026-10-10), so it + // runs in its own lane. The PR and release matrices both carry that lane. + const workspacesC = dryRunJson(["--mode", "general", "--group", "general-workspaces-c"]); + assert.deepEqual(workspacesC.workspaceProjects, ["@paperclipai/db"]); + assert.equal(workspacesC.workspacesVitestShard, null); + const workspacesB = dryRunJson(["--mode", "general", "--group", "general-workspaces-b"]); + assert.ok(!workspacesB.workspaceProjects.includes("@paperclipai/db"), "db must not run twice"); + assert.ok(workspacesB.workspaceProjects.length > 0, "workspaces-b must still run its other projects"); + for (const workflow of ["pr-trusted.yml", "release-verify.yml"]) { + const entries = matrixEntries(readFileSync(path.join(repoRoot, ".github", "workflows", workflow), "utf8")); + // The parser must demonstrably see shard keys in this workflow, otherwise + // the unsharded assertions below could pass for the wrong reason. + const sharded = entries.filter(entry => entry.group === "general-workspaces-a"); + assert.ok(sharded.length > 0 && sharded.every(entry => entry.keys.includes("shard_index") && entry.keys.includes("shard_count")), + `${workflow} must carry the sharded workspaces-a entries`); + for (const group of ["general-workspaces-b", "general-workspaces-c"]) { + const matching = entries.filter(entry => entry.group === group); + assert.equal(matching.length, 1, `${workflow} must run ${group} exactly once`); + assert.ok(matching[0].keys.includes("group_label"), `${workflow} must label the ${group} job`); + assert.deepEqual(matching[0].keys.filter(key => key.startsWith("shard_")), [], `${workflow} must run ${group} unsharded`); + } + } +}); + test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => { const shards = [0, 1].map((index) => dryRunJson([ diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index dd00f145cda..a62f90094e1 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -117,9 +117,27 @@ function excludedServerSuites(groupName) { } const generalWorkspacesAGroupName = "general-workspaces-a"; const generalWorkspacesBGroupName = "general-workspaces-b"; +const generalWorkspacesCGroupName = "general-workspaces-c"; const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; -const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project)); -const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName]; +// The db project runs in its own lane. Its vitest invocation took 168-250s +// of a 436-513s workspaces-b job (PR runs 38084171761, 38083084276 and +// 38083020834, 2026-10-10), which made workspaces-b the slowest check once +// the server shards were rebalanced. Vitest's native --shard would not help +// here: the project is already file-parallel and client.test.ts alone is +// 117-189s of its wall time, so a hash-ordered slice can land both of its +// slow suites on one runner. Splitting by project is deterministic and +// leaves the remaining projects at ~265-275s of vitest time, roughly level +// with this lane. +const generalWorkspacesCProjects = ["@paperclipai/db"]; +const generalWorkspacesBProjects = nonServerProjects.filter( + (project) => !generalWorkspacesAProjects.includes(project) && !generalWorkspacesCProjects.includes(project), +); +const generalGroupNames = [ + generalServerGroupName, + generalWorkspacesAGroupName, + generalWorkspacesBGroupName, + generalWorkspacesCGroupName, +]; const allowedGeneralGroupNames = [ ...generalGroupNames, generalServerWithoutChatGroupName, @@ -520,6 +538,11 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = return; } + if (groupName === generalWorkspacesCGroupName) { + runProjectGroup(generalWorkspacesCProjects, groupName); + return; + } + fail(`Unknown group "${groupName}".`); } @@ -604,7 +627,9 @@ if (options.dryRun) { ? generalWorkspacesAProjects : options.group === generalWorkspacesBGroupName ? generalWorkspacesBProjects - : null, + : options.group === generalWorkspacesCGroupName + ? generalWorkspacesCProjects + : null, workspacesVitestShard: options.group === generalWorkspacesAGroupName && options.shardCount !== null &&