mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
ci: split plugin contract shards
This commit is contained in:
132
.github/workflows/ci.yml
vendored
132
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
86
scripts/lib/plugin-contract-test-plan.mjs
Normal file
86
scripts/lib/plugin-contract-test-plan.mjs
Normal 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);
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
65
test/scripts/plugin-contract-test-plan.test.ts
Normal file
65
test/scripts/plugin-contract-test-plan.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user