diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea2f58fd49f..baf99c717cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/scripts/lib/plugin-contract-test-plan.mjs b/scripts/lib/plugin-contract-test-plan.mjs new file mode 100644 index 00000000000..6cca550e53b --- /dev/null +++ b/scripts/lib/plugin-contract-test-plan.mjs @@ -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); +} diff --git a/src/plugins/contracts/provider-family-plugin-tests.test.ts b/src/plugins/contracts/provider-family-plugin-tests.test.ts index c76444b85c4..1038cc8ef9c 100644 --- a/src/plugins/contracts/provider-family-plugin-tests.test.ts +++ b/src/plugins/contracts/provider-family-plugin-tests.test.ts @@ -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 { const inventory = new Map(); @@ -132,7 +140,7 @@ function listMatchingFamilies(source: string, pattern: RegExp): string[] { function collectSharedFamilyAssignments(): Map { const inventory = new Map(); - 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]), diff --git a/test/scripts/plugin-contract-test-plan.test.ts b/test/scripts/plugin-contract-test-plan.test.ts new file mode 100644 index 00000000000..54672ea6a55 --- /dev/null +++ b/test/scripts/plugin-contract-test-plan.test.ts @@ -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); + } + }); +});