feat(FN-3366): document CI shard contract in contributing guide

Documentation update to `docs/contributing.md` adding details on the CI shard contract (FN-3366 Step 3).

Fusion-Task-Id: FN-3366
This commit is contained in:
Fusion
2026-05-04 08:17:52 -07:00
committed by gsxdsm
parent c7b9afcdd0
commit 7cd7872e5a
8 changed files with 253 additions and 52 deletions

View File

@@ -5,20 +5,16 @@ on:
workflow_dispatch:
jobs:
ci:
name: Build & Test
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
node-version: "24"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
node-version: "24"
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -29,8 +25,62 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify workspace bootstrap contract (lint -> test -> build)
run: pnpm verify:workspace
- name: Lint
run: pnpm lint
test-shards:
name: Test shard ${{ matrix.shard }}/3
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Test (deterministic shard)
run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test-shards]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace
run: pnpm build
- name: Install Bun
uses: oven-sh/setup-bun@v2

View File

@@ -9,10 +9,60 @@ concurrency:
cancel-in-progress: true
jobs:
checks:
name: Lint, Typecheck, Test
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
test-shards:
name: Test shard ${{ matrix.shard }}/3
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -32,11 +82,5 @@ jobs:
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Test (full suite)
run: pnpm test:full
- name: Test (deterministic shard)
run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3

View File

@@ -64,7 +64,13 @@ Fusion codifies workspace verification as a deterministic contract:
2. `pnpm test:full`
3. `pnpm build`
CI uses `pnpm verify:workspace` directly, so changes that reintroduce hidden test pre-build dependencies fail fast.
GitHub Actions now runs deterministic test sharding via `pnpm test:ci:shard --shard <index> --total <count>` in both PR checks and manual CI, while keeping local semantics unchanged:
- `pnpm test` remains changed-only local iteration.
- `pnpm test:full` remains the canonical full local suite.
- `pnpm verify:workspace` remains the canonical local lint -> test -> build gate.
`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that partitions a fixed package list by shard index modulo total shard count so coverage is deterministic and reproducible.
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected package test scripts using safe package-first filtering (`pnpm --filter <pkg> test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, or shared/root test infrastructure changes.

View File

@@ -27,6 +27,7 @@
"build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all",
"test": "node scripts/test-changed.mjs",
"test:full": "pnpm sync:fusion-skill:check && FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test",
"test:ci:shard": "node scripts/ci-test-shard.mjs",
"test:serial": "FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=1 pnpm -r --workspace-concurrency=1 test",
"test:fast": "FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=4 pnpm -r --workspace-concurrency=4 test",
"test:locked": "node scripts/test-with-lock.mjs",

View File

@@ -22,7 +22,8 @@ function loadWorkflow(name: string): any {
describe("CI workflow (.github/workflows/ci.yml)", () => {
let workflow: any;
let content: string;
let ciSteps: any[];
let buildSteps: any[];
let testShardJob: any;
let contributingContent: string;
let readmeContent: string;
let cliPackageJsonContent: string;
@@ -34,7 +35,8 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
const result = loadWorkflow("ci.yml");
workflow = result.parsed;
content = result.content;
ciSteps = workflow.jobs?.ci?.steps ?? [];
buildSteps = workflow.jobs?.build?.steps ?? [];
testShardJob = workflow.jobs?.["test-shards"];
contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8");
readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8");
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
@@ -52,13 +54,8 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
);
});
const findStepByRun = (runSnippet: string) => ciSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
const findStepByRunExact = (runCommand: string) =>
ciSteps.find((step) => typeof step.run === "string" && step.run.trim() === runCommand);
const findStepIndexByRun = (runSnippet: string) =>
ciSteps.findIndex((step) => typeof step.run === "string" && step.run.includes(runSnippet));
const findBuildStepByRun = (runSnippet: string) =>
buildSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
it("is valid YAML", () => {
expect(workflow).toBeDefined();
@@ -80,26 +77,21 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
expect(content).not.toContain("--no-frozen-lockfile");
});
it("uses verify:workspace as the single lint/test/build contract", () => {
const verifyStep = findStepByRun("pnpm verify:workspace");
expect(verifyStep).toBeDefined();
expect(verifyStep.name).toContain("bootstrap contract");
it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => {
expect(workflow.jobs?.lint).toBeDefined();
expect(testShardJob).toBeDefined();
expect(workflow.jobs?.build).toBeDefined();
const directLintStep = findStepByRunExact("pnpm lint");
const directTestStep = findStepByRunExact("pnpm test");
const directBuildStep = findStepByRunExact("pnpm build");
expect(directLintStep).toBeUndefined();
expect(directTestStep).toBeUndefined();
expect(directBuildStep).toBeUndefined();
expect(testShardJob.strategy?.matrix?.shard).toEqual([1, 2, 3]);
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
expect(content).not.toContain("pnpm verify:workspace");
});
it("runs workspace verification before slow lane and binary packaging", () => {
const verifyIdx = findStepIndexByRun("pnpm verify:workspace");
const slowLaneIdx = findStepIndexByRun("pnpm test:slow-cli");
const buildExeIdx = findStepIndexByRun("build:exe");
expect(verifyIdx).toBeGreaterThanOrEqual(0);
expect(slowLaneIdx).toBeGreaterThan(verifyIdx);
expect(buildExeIdx).toBeGreaterThan(slowLaneIdx);
it("runs build job after lint and sharded tests, then executes slow lane and binary packaging", () => {
expect(workflow.jobs?.build?.needs).toEqual(["lint", "test-shards"]);
expect(findBuildStepByRun("pnpm build")).toBeDefined();
expect(findBuildStepByRun("pnpm test:slow-cli")).toBeDefined();
expect(findBuildStepByRun("build:exe")).toBeDefined();
});
it("keeps contributing docs aligned with verification and slow-lane contracts", () => {
@@ -158,13 +150,11 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
let workflow: any;
let content: string;
let steps: any[];
beforeAll(() => {
const result = loadWorkflow("pr-checks.yml");
workflow = result.parsed;
content = result.content;
steps = workflow.jobs?.checks?.steps ?? [];
});
it("is valid YAML", () => {
@@ -176,10 +166,12 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
expect(workflow.on?.pull_request?.branches).toContain("main");
});
it("runs explicit full-suite tests (not changed-only root test)", () => {
const testStep = steps.find((step: any) => step.name?.includes("Test"));
expect(testStep).toBeDefined();
expect(testStep.run).toBe("pnpm test:full");
it("uses the same deterministic test sharding command as manual CI", () => {
expect(workflow.jobs?.lint).toBeDefined();
expect(workflow.jobs?.typecheck).toBeDefined();
expect(workflow.jobs?.["test-shards"]).toBeDefined();
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3]);
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
expect(content).not.toContain("run: pnpm test\n");
});
});

View File

@@ -122,10 +122,11 @@ describe("Scoped @fusion/* packages publishing config", () => {
describe("Workspace bootstrap script contract", () => {
const rootPkg = loadRootPackageJson();
it("makes root test changed-only while keeping explicit full-suite command", () => {
it("makes root test changed-only while keeping explicit full-suite and CI-shard commands", () => {
expect(rootPkg.scripts?.test).toBe("node scripts/test-changed.mjs");
expect(rootPkg.scripts?.["test:full"]).toContain("pnpm -r --workspace-concurrency=2 test");
expect(rootPkg.scripts?.["test:full"]).not.toContain("pnpm build");
expect(rootPkg.scripts?.["test:ci:shard"]).toBe("node scripts/ci-test-shard.mjs");
});
it("defines verify:workspace in lint -> test:full -> build order", () => {

View File

@@ -4,6 +4,7 @@ import {
resolveAffectedPackages,
shouldForceFullSuite,
} from "../../../../scripts/test-changed.mjs";
import { parseShardArgs, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
describe("root test command changed-only planning", () => {
it("uses changed mode when package-only changes are detected", () => {
@@ -55,3 +56,25 @@ describe("root test command changed-only planning", () => {
expect(shouldForceFullSuite(["packages/core/src/store.ts"])).toBe(false);
});
});
describe("CI shard test planner", () => {
it("parses valid shard args", () => {
expect(parseShardArgs(["--shard", "2", "--total", "3"], {} as NodeJS.ProcessEnv)).toEqual({
shard: 2,
total: 3,
});
});
it("rejects invalid shard args", () => {
expect(() => parseShardArgs(["--shard", "4", "--total", "3"], {} as NodeJS.ProcessEnv)).toThrow(
"Usage: node scripts/ci-test-shard.mjs --shard <1..N> --total <N>",
);
});
it("selects deterministic package partitions", () => {
const packages = ["a", "b", "c", "d", "e"];
expect(selectShardPackages(packages, 1, 3)).toEqual(["a", "d"]);
expect(selectShardPackages(packages, 2, 3)).toEqual(["b", "e"]);
expect(selectShardPackages(packages, 3, 3)).toEqual(["c"]);
});
});

84
scripts/ci-test-shard.mjs Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_TEST_PACKAGES = [
"@fusion/core",
"@fusion/engine",
"@fusion/dashboard",
"@runfusion/fusion",
"@fusion/plugin-sdk",
"@fusion/desktop",
"@fusion/mobile",
"@fusion/droid-cli",
"@fusion/pi-claude-cli",
];
function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, {
cwd: process.cwd(),
stdio: "inherit",
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function parsePositiveInteger(value) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
export function parseShardArgs(argv = process.argv.slice(2), env = process.env) {
const byFlag = (name) => {
const idx = argv.indexOf(name);
return idx >= 0 ? argv[idx + 1] : undefined;
};
const shard = parsePositiveInteger(byFlag("--shard") ?? env.CI_SHARD_INDEX);
const total = parsePositiveInteger(byFlag("--total") ?? env.CI_SHARD_TOTAL);
if (!shard || !total || shard > total) {
throw new Error("Usage: node scripts/ci-test-shard.mjs --shard <1..N> --total <N>");
}
return { shard, total };
}
export function selectShardPackages(packages, shard, total) {
return packages.filter((_, index) => index % total === shard - 1);
}
export function main(argv = process.argv.slice(2), env = process.env) {
const { shard, total } = parseShardArgs(argv, env);
const shardPackages = selectShardPackages(DEFAULT_TEST_PACKAGES, shard, total);
if (shardPackages.length === 0) {
console.log(`[ci-test-shard] shard ${shard}/${total} has no assigned packages; skipping.`);
return;
}
console.log(`[ci-test-shard] shard ${shard}/${total}: ${shardPackages.join(", ")}`);
const shardEnv = {
...env,
FUSION_TEST_TOTAL_WORKERS: env.FUSION_TEST_TOTAL_WORKERS || "4",
FUSION_TEST_CONCURRENCY: env.FUSION_TEST_CONCURRENCY || "1",
};
run("pnpm", ["sync:fusion-skill:check"], { env: shardEnv });
const filters = shardPackages.flatMap((pkg) => ["--filter", pkg]);
run("pnpm", [...filters, "test"], { env: shardEnv });
}
const currentFilePath = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
main();
}