From d648becb90a346818e83d42c4439e2456228c657 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 10 Aug 2026 17:32:44 -0700 Subject: [PATCH] refactor(ci): split workspaces-a into two Vitest native shards Split the slow workspaces-a CI lane into two Vitest native shards and keep release verification in parity. Co-Authored-By: Paperclip --- .github/workflows/pr.yml | 14 +++++++- .github/workflows/release-verify.yml | 10 +++++- .../release-verify-workflow.test.mjs | 9 +++++ .../run-vitest-stable-shard.test.mjs | 34 +++++++++++++++++-- scripts/run-vitest-stable.mjs | 34 ++++++++++++++++--- 5 files changed, 91 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 097692cdec..925c520ba4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -174,8 +174,20 @@ jobs: group_label: server (5/5) shard_index: 4 shard_count: 5 + # workspaces-a was the slowest check in the fully-green PR run + # 31371439296 (2026-08-10) at 319s, with the ui project's single + # vitest invocation accounting for ~224s and the paperclipai CLI + # ~37s. Two shards use Vitest's native --shard on each project's + # file list (ui: 439 files, cli: 54), bringing each job to roughly + # half the suite time (~130s + setup) without a duration manifest. - group: general-workspaces-a - group_label: workspaces-a + group_label: workspaces-a (1/2) + shard_index: 0 + shard_count: 2 + - group: general-workspaces-a + group_label: workspaces-a (2/2) + shard_index: 1 + shard_count: 2 - group: general-workspaces-b group_label: workspaces-b diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index cac52d75b5..df146e7faa 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -64,8 +64,16 @@ jobs: group_label: server (3/3) shard_index: 2 shard_count: 3 + # Keep parity with pr.yml: workspaces-a is split with Vitest's + # native --shard because the ui project dominates the lane. - group: general-workspaces-a - group_label: workspaces-a + group_label: workspaces-a (1/2) + shard_index: 0 + shard_count: 2 + - group: general-workspaces-a + group_label: workspaces-a (2/2) + shard_index: 1 + shard_count: 2 - group: general-workspaces-b group_label: workspaces-b diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 3e33d0b254..02de233cb6 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -84,6 +84,15 @@ test("release verify workflow covers the same split test surface as stable PR ve assert.match(verifyWorkflow, new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`)); } + // workspaces-a splits with Vitest native --shard in pr.yml; release + // verification must keep the same two-shard coverage. + for (const shardIndex of [0, 1]) { + assert.match( + verifyWorkflow, + new RegExp(`group: general-workspaces-a[\\s\\S]*?shard_index: ${shardIndex}\\n\\s+shard_count: 2`), + ); + } + assert.match(verifyWorkflow, /pnpm test:run:general -- --group/); assert.match(verifyWorkflow, /pnpm test:run:serialized -- --shard-index/); }); diff --git a/scripts/__tests__/run-vitest-stable-shard.test.mjs b/scripts/__tests__/run-vitest-stable-shard.test.mjs index f3ef491e8f..ea74a3de12 100644 --- a/scripts/__tests__/run-vitest-stable-shard.test.mjs +++ b/scripts/__tests__/run-vitest-stable-shard.test.mjs @@ -77,9 +77,37 @@ test("a route/authz suite never leaks into the general-server shards", () => { } }); -test("shard flags are rejected for the parallel workspace groups", () => { - const result = dryRun(["--mode", "general", "--group", "general-workspaces-a", "--shard-index", "0", "--shard-count", "3"]); - assert.notEqual(result.status, 0, "workspace groups must not accept shard flags"); +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"); +}); + +test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => { + const shards = [0, 1].map((index) => + dryRunJson([ + "--mode", "general", "--group", "general-workspaces-a", + "--shard-index", String(index), "--shard-count", "2", + ]), + ); + + assert.deepEqual( + shards.map((shard) => shard.workspacesVitestShard), + ["1/2", "2/2"], + "each matrix job must pass its own --shard slice to vitest", + ); + // Vitest's --shard partitions each project's file list deterministically, so + // an identical project list across jobs is what guarantees complete, + // non-overlapping coverage of the lane. + assert.deepEqual(shards[0].workspaceProjects, shards[1].workspaceProjects); + assert.ok(shards[0].workspaceProjects.length > 0, "workspaces-a must run at least one project"); + + const unsharded = dryRunJson(["--mode", "general", "--group", "general-workspaces-a"]); + assert.deepEqual( + unsharded.workspaceProjects, + shards[0].workspaceProjects, + "sharding must not change which projects the lane covers", + ); + assert.equal(unsharded.workspacesVitestShard, null); }); test("duration-aware partition balances skewed weights better than round-robin", () => { diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 7c5a6c9c8d..d8b334e58f 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -208,10 +208,11 @@ function parseCliOptions(argv) { const shardAllowed = mode === serializedModeName || - (mode === generalModeName && group === generalServerGroupName); + (mode === generalModeName && + (group === generalServerGroupName || group === generalWorkspacesAGroupName)); if (!shardAllowed && shardIndex !== null) { fail( - "--shard-index/--shard-count are only valid with --mode serialized or --mode general --group general-server.", + "--shard-index/--shard-count are only valid with --mode serialized, --mode general --group general-server, or --mode general --group general-workspaces-a.", ); } @@ -287,9 +288,16 @@ function runGeneralSuites(routeTests) { } } -function runProjectGroup(projects, groupName) { +function runProjectGroup(projects, groupName, shardIndex = null, shardCount = null) { + // With shard args, lean on Vitest's native --shard: each matrix job runs the + // same per-project invocations but only its slice of each project's test + // files. Vitest's sharding is deterministic for an identical file list, so + // the matrix jobs form a complete, non-overlapping cover of every project. + const shardArgs = + shardCount !== null && shardCount > 1 ? [`--shard=${shardIndex + 1}/${shardCount}`] : []; + const shardSuffix = shardArgs.length > 0 ? ` shard ${shardIndex + 1}/${shardCount}` : ""; for (const project of projects) { - runVitest(["--project", project], `${groupName} project ${project}`); + runVitest(["--project", project, ...shardArgs], `${groupName} project ${project}${shardSuffix}`); } } @@ -335,7 +343,11 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = } if (groupName === generalWorkspacesAGroupName) { - runProjectGroup(generalWorkspacesAProjects, groupName); + // The ui project dominates this lane (~224s of a 319s job in actions run + // 31371439296, 2026-08-10, where workspaces-a was the slowest PR check). + // Its 439 test files shard cleanly with Vitest's native --shard, so the + // lane splits across runners without a duration manifest. + runProjectGroup(generalWorkspacesAProjects, groupName, shardIndex, shardCount); return; } @@ -416,6 +428,18 @@ if (options.dryRun) { generalServerShardDurations, ) : null, + workspaceProjects: + options.group === generalWorkspacesAGroupName + ? generalWorkspacesAProjects + : options.group === generalWorkspacesBGroupName + ? generalWorkspacesBProjects + : null, + workspacesVitestShard: + options.group === generalWorkspacesAGroupName && + options.shardCount !== null && + options.shardCount > 1 + ? `${options.shardIndex + 1}/${options.shardCount}` + : null, }, null, 2,