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:
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
64
.github/workflows/pr-checks.yml
vendored
64
.github/workflows/pr-checks.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
84
scripts/ci-test-shard.mjs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user