ci: split plugin contract shards

This commit is contained in:
Peter Steinberger
2026-04-29 11:44:28 +01:00
parent 6b44dce0c8
commit 6b4873d0c1
4 changed files with 282 additions and 19 deletions

View File

@@ -54,6 +54,8 @@ jobs:
run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
run_plugin_contracts_shards: ${{ steps.manifest.outputs.run_plugin_contracts_shards }}
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
@@ -139,6 +141,9 @@ jobs:
import {
createChannelContractTestShards,
} from "./scripts/lib/channel-contract-test-plan.mjs";
import {
createPluginContractTestShards,
} from "./scripts/lib/plugin-contract-test-plan.mjs";
const parseBoolean = (value, fallback = false) => {
if (value === undefined) return fallback;
@@ -161,7 +166,7 @@ jobs:
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS);
const runNodeFastCiRouting =
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING);
const runChecksFastCore = runNodeFull || runNodeFastPluginContracts || runNodeFastCiRouting;
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
const runMacos =
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
const runAndroid =
@@ -178,20 +183,9 @@ jobs:
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{
check_name: "checks-fast-contracts-plugins",
runtime: "node",
task: "contracts-plugins",
},
);
} else {
if (runNodeFastPluginContracts) {
checksFastCoreTasks.push({
check_name: "checks-fast-contracts-plugins",
runtime: "node",
task: runNodeFastCiRouting ? "contracts-plugins-ci-routing" : "contracts-plugins",
});
} else if (runNodeFastCiRouting) {
if (runNodeFastCiRouting) {
checksFastCoreTasks.push({
check_name: "checks-fast-ci-routing",
runtime: "node",
@@ -226,9 +220,13 @@ jobs:
run_skills_python: runSkillsPython,
run_windows: runWindows,
run_build_artifacts: runNodeFull,
run_checks_fast_core: runChecksFastCore,
run_checks_fast_core: checksFastCoreTasks.length > 0,
run_checks_fast: runNodeFull,
checks_fast_core_matrix: createMatrix(checksFastCoreTasks),
run_plugin_contracts_shards: runPluginContractShards,
plugin_contracts_matrix: createMatrix(
runPluginContractShards ? createPluginContractTestShards() : [],
),
channel_contracts_matrix: createMatrix(
runNodeFull ? createChannelContractTestShards() : [],
),
@@ -730,6 +728,112 @@ jobs:
;;
esac
checks-fast-plugin-contracts-shard:
permissions:
contents: read
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run plugin contract shard
env:
OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
shell: bash
run: |
set -euo pipefail
include_file="$RUNNER_TEMP/plugin-contract-include.json"
INCLUDE_FILE="$include_file" node --input-type=module <<'EOF'
import { writeFileSync } from "node:fs";
const includePatterns = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "[]");
if (!Array.isArray(includePatterns) || includePatterns.length === 0) {
console.error("Missing plugin contract include patterns");
process.exit(1);
}
writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(includePatterns), "utf8");
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
checks-fast-plugin-contracts:
permissions:
contents: read
name: checks-fast-contracts-plugins
needs: [preflight, checks-fast-plugin-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_contracts_shards == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify plugin contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-plugin-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Plugin contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Plugin contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-channel-contracts-shard:
permissions:
contents: read

View File

@@ -0,0 +1,86 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
function listContractTestFiles(rootDir = "src/plugins/contracts") {
if (!existsSync(rootDir)) {
return [];
}
const files = [];
const visit = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
visit(path);
continue;
}
if (entry.isFile() && entry.name.endsWith(".test.ts")) {
files.push(path.replaceAll("\\", "/"));
}
}
};
visit(rootDir);
return files.toSorted((a, b) => a.localeCompare(b));
}
const CONTRACT_FILE_WEIGHTS = new Map([
["plugin-sdk-subpaths.test.ts", 80],
["plugin-sdk-root-alias.test.ts", 90],
["tts.contract.test.ts", 70],
["boundary-invariants.test.ts", 36],
["extension-package-project-boundaries.test.ts", 34],
["plugin-sdk-index.test.ts", 32],
["plugin-sdk-index.bundle.test.ts", 32],
["plugin-sdk-package-contract-guardrails.test.ts", 46],
["providers.contract.test.ts", 30],
["registry.contract.test.ts", 30],
["core-extension-facade-boundary.test.ts", 28],
["loader.contract.test.ts", 28],
["runtime-import-side-effects.contract.test.ts", 24],
["extension-runtime-dependencies.contract.test.ts", 22],
]);
function resolveContractFileWeight(file) {
const name = file.replaceAll("\\", "/").split("/").pop();
if (name.startsWith("plugin-registration.")) {
return 14;
}
if (name.startsWith("wizard.")) {
return 12;
}
return CONTRACT_FILE_WEIGHTS.get(name) ?? 10;
}
export function createPluginContractTestShards() {
const suffixes = ["a", "b", "c", "d"];
const groups = Object.fromEntries(
suffixes.map((suffix) => [`checks-fast-contracts-plugins-${suffix}`, []]),
);
const groupKeys = suffixes.map((suffix) => `checks-fast-contracts-plugins-${suffix}`);
const weights = Object.fromEntries(groupKeys.map((key) => [key, 0]));
const pushBalanced = (file) => {
const target = groupKeys.toSorted((a, b) => weights[a] - weights[b] || a.localeCompare(b))[0];
groups[target].push(file);
weights[target] += resolveContractFileWeight(file);
};
const byDescendingWeight = (left, right) => {
const delta = resolveContractFileWeight(right) - resolveContractFileWeight(left);
return delta === 0 ? left.localeCompare(right) : delta;
};
for (const file of listContractTestFiles().toSorted(byDescendingWeight)) {
pushBalanced(file);
}
return Object.entries(groups)
.map(([checkName, includePatterns]) => ({
checkName,
includePatterns,
runtime: "node",
task: "contracts-plugins",
}))
.filter((shard) => shard.includePatterns.length > 0);
}

View File

@@ -1,5 +1,5 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, relative, resolve, sep } from "node:path";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
@@ -72,11 +72,19 @@ function listBundledPluginRoots() {
.plugins.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => ({
pluginId: plugin.id,
rootDir: plugin.workspaceDir ?? plugin.rootDir,
rootDir: resolveBundledPluginSourceRoot(plugin.rootDir, plugin.workspaceDir),
}))
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
}
function resolveBundledPluginSourceRoot(rootDir: string, workspaceDir?: string): string {
if (workspaceDir) {
return workspaceDir;
}
const sourceRoot = resolve(REPO_ROOT, "extensions", basename(rootDir));
return existsSync(sourceRoot) ? sourceRoot : rootDir;
}
function collectSharedFamilyProviders(): Map<string, SharedFamilyProviderInventory> {
const inventory = new Map<string, SharedFamilyProviderInventory>();
@@ -132,7 +140,7 @@ function listMatchingFamilies(source: string, pattern: RegExp): string[] {
function collectSharedFamilyAssignments(): Map<string, ExpectedSharedFamilyContract> {
const inventory = new Map<string, ExpectedSharedFamilyContract>();
const replayPattern = /buildProviderReplayFamilyHooks\s*\(\s*\{\s*family:\s*"([^"]+)"/gu;
const replayPattern = /buildProviderReplayFamilyHooks\s*\(\s*\{[\s\S]*?\bfamily:\s*"([^"]+)"/gu;
const streamPattern = /buildProviderStreamFamilyHooks\s*\(\s*"([^"]+)"/gu;
const toolCompatPattern = /buildProviderToolCompatFamilyHooks\s*\(\s*"([^"]+)"/gu;
@@ -201,7 +209,7 @@ describe("provider family plugin-boundary inventory", () => {
for (const [pluginId, expected] of Object.entries(
EXPECTED_SENTINEL_SHARED_FAMILY_ASSIGNMENTS,
)) {
expect(actualAssignments[pluginId]).toBeDefined();
expect(actualAssignments[pluginId], pluginId).toBeDefined();
if (expected.replayFamilies) {
expect(actualAssignments[pluginId]?.replayFamilies ?? []).toEqual(
expect.arrayContaining([...expected.replayFamilies]),

View File

@@ -0,0 +1,65 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createPluginContractTestShards } from "../../scripts/lib/plugin-contract-test-plan.mjs";
function listContractTests(rootDir = "src/plugins/contracts"): string[] {
if (!existsSync(rootDir)) {
return [];
}
const files: string[] = [];
const visit = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
visit(path);
continue;
}
if (entry.isFile() && entry.name.endsWith(".test.ts")) {
files.push(path.replaceAll("\\", "/"));
}
}
};
visit(rootDir);
return files.toSorted((a, b) => a.localeCompare(b));
}
describe("scripts/lib/plugin-contract-test-plan.mjs", () => {
it("splits plugin contracts into focused shards", () => {
const suffixes = ["a", "b", "c", "d"];
expect(
createPluginContractTestShards().map((shard) => ({
checkName: shard.checkName,
runtime: shard.runtime,
task: shard.task,
})),
).toEqual(
suffixes.map((suffix) => ({
checkName: `checks-fast-contracts-plugins-${suffix}`,
runtime: "node",
task: "contracts-plugins",
})),
);
});
it("covers every plugin contract test exactly once", () => {
const actual = createPluginContractTestShards()
.flatMap((shard) => shard.includePatterns)
.toSorted((a, b) => a.localeCompare(b));
expect(actual).toEqual(listContractTests());
expect(new Set(actual).size).toBe(actual.length);
});
it("keeps plugin registration contract files spread across checks", () => {
for (const shard of createPluginContractTestShards()) {
const registrationFiles = shard.includePatterns.filter((pattern) =>
pattern.includes("/plugin-registration."),
);
expect(registrationFiles.length).toBeLessThanOrEqual(7);
}
});
});