diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 573a3efb308..5ec6378774e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }} changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }} run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }} + 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 }} channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }} @@ -130,6 +131,9 @@ jobs: OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }} OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }} OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ steps.changed_scope.outputs.run_node_fast_only || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }} OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }} OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }} OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }} @@ -173,12 +177,23 @@ jobs: const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY); const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED); const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly; + const runNodeFastOnly = + runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY); + const runNodeFull = runNode && !runNodeFastOnly; + const runNodeFastPluginContracts = + 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 runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository; const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository; const runWindows = - parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly && isCanonicalRepository; + parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && + !docsOnly && + !runNodeFastOnly && + isCanonicalRepository; const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly; const runControlUiI18n = parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly; @@ -191,7 +206,7 @@ jobs: ? DEFAULT_EXTENSION_TEST_SHARD_COUNT : Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36); const extensionShardMatrix = createMatrix( - runNode + runNodeFull ? createExtensionTestShards({ shardCount: extensionTestShardCount, }).map((shard) => ({ @@ -207,7 +222,33 @@ jobs: })) : [], ); - const nodeTestShards = runNode + const checksFastCoreTasks = []; + 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) { + checksFastCoreTasks.push({ + check_name: "checks-fast-ci-routing", + runtime: "node", + task: "ci-routing", + }); + } + } + + const nodeTestShards = runNodeFull ? createNodeTestShards().map((shard) => ({ check_name: shard.checkName, runtime: "node", @@ -232,25 +273,17 @@ jobs: run_windows: runWindows, has_changed_extensions: hasChangedExtensions, changed_extensions_matrix: changedExtensionsMatrix, - run_build_artifacts: runNode, - run_checks_fast: runNode, - checks_fast_core_matrix: createMatrix( - runNode - ? [ - { check_name: "checks-fast-bundled", runtime: "node", task: "bundled" }, - { - check_name: "checks-fast-contracts-plugins", - runtime: "node", - task: "contracts-plugins", - }, - ] - : [], + run_build_artifacts: runNodeFull, + run_checks_fast_core: runChecksFastCore, + run_checks_fast: runNodeFull, + checks_fast_core_matrix: createMatrix(checksFastCoreTasks), + channel_contracts_matrix: createMatrix( + runNodeFull ? createChannelContractTestShards() : [], ), - channel_contracts_matrix: createMatrix(runNode ? createChannelContractTestShards() : []), checks_node_extensions_matrix: extensionShardMatrix, - run_checks: runNode, + run_checks: runNodeFull, checks_matrix: createMatrix( - runNode + runNodeFull ? [ { check_name: "checks-node-channels", runtime: "node", task: "channels" }, ] @@ -269,9 +302,9 @@ jobs: })) : [], ), - run_check: runNode, - run_check_additional: runNode, - run_build_smoke: runNode, + run_check: runNodeFull, + run_check_additional: runNodeFull, + run_build_smoke: runNodeFull, run_check_docs: docsChanged, run_control_ui_i18n: runControlUiI18n, run_skills_python_job: runSkillsPython, @@ -662,7 +695,7 @@ jobs: contents: read name: ${{ matrix.check_name }} needs: [preflight] - if: needs.preflight.outputs.run_checks_fast == 'true' + if: needs.preflight.outputs.run_checks_fast_core == 'true' runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: @@ -739,6 +772,13 @@ jobs: contracts-plugins) pnpm test:contracts:plugins ;; + contracts-plugins-ci-routing) + pnpm test:contracts:plugins + pnpm test src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts + ;; + ci-routing) + pnpm test src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts + ;; *) echo "Unsupported checks-fast task: $TASK" >&2 exit 1 @@ -1044,7 +1084,7 @@ jobs: contents: read name: checks-node-compat-node22 needs: [preflight] - if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push' + if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'push' runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 steps: diff --git a/docs/ci.md b/docs/ci.md index 1350ab0358e..d67783bfeec 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -90,6 +90,7 @@ Jobs are ordered so cheap checks fail before expensive ones run: Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes. +CI routing-only edits and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards. The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 120-second command timeout. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 427cf9dcb9c..c4bff734296 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; /** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean }} ChangedScope */ +/** @typedef {{ runFastOnly: boolean; runPluginContracts: boolean; runCiRouting: boolean }} NodeFastScope */ /** @typedef {{ runFastInstallSmoke: boolean; runFullInstallSmoke: boolean }} InstallSmokeScope */ const FULL_SCOPE = { @@ -49,6 +50,13 @@ const FAST_INSTALL_SMOKE_SCOPE_RE = const FULL_INSTALL_SMOKE_SCOPE_RE = /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/install\.sh$|scripts\/test-install-sh-docker\.sh$|scripts\/docker\/|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|qr-import-docker\.sh|bun-global-install-smoke\.sh)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE = /^src\/(?:channels|gateway|plugin-sdk|plugins)\//; +const NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE = + /^(src\/plugins\/contracts\/(?:inventory\/bundled-capability-metadata|registry)\.ts$|test\/helpers\/plugins\/tts-contract-suites\.ts$|scripts\/test-projects(?:\.test-support)?\.mjs$|test\/scripts\/test-projects\.test\.ts$)/; +const NODE_FAST_CI_ROUTING_SCOPE_RE = + /^(scripts\/ci-changed-scope\.mjs$|src\/scripts\/ci-changed-scope\.test\.ts$|\.github\/workflows\/ci\.yml$)/; +const NODE_FAST_SCOPE_RE = new RegExp( + `${NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.source}|${NODE_FAST_CI_ROUTING_SCOPE_RE.source}`, +); /** * @param {string[]} changedPaths @@ -144,6 +152,42 @@ export function detectChangedScope(changedPaths) { }; } +/** + * @param {string[]} changedPaths + * @returns {NodeFastScope} + */ +export function detectNodeFastScope(changedPaths) { + if (!Array.isArray(changedPaths) || changedPaths.length === 0) { + return { runFastOnly: false, runPluginContracts: false, runCiRouting: false }; + } + + let hasNonDocs = false; + let runPluginContracts = false; + let runCiRouting = false; + + for (const rawPath of changedPaths) { + const path = rawPath.trim(); + if (!path || DOCS_PATH_RE.test(path)) { + continue; + } + + hasNonDocs = true; + runPluginContracts ||= NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.test(path); + runCiRouting ||= NODE_FAST_CI_ROUTING_SCOPE_RE.test(path); + + if (!NODE_FAST_SCOPE_RE.test(path)) { + return { runFastOnly: false, runPluginContracts: false, runCiRouting: false }; + } + } + + const runFastOnly = hasNonDocs && (runPluginContracts || runCiRouting); + return { + runFastOnly, + runPluginContracts: runFastOnly && runPluginContracts, + runCiRouting: runFastOnly && runCiRouting, + }; +} + /** * @param {string} path * @returns {InstallSmokeScope} @@ -211,6 +255,7 @@ export function writeGitHubOutput( runFastInstallSmoke: scope.runChangedSmoke, runFullInstallSmoke: scope.runChangedSmoke, }, + nodeFastScope = { runFastOnly: false, runPluginContracts: false, runCiRouting: false }, ) { if (!outputPath) { throw new Error("GITHUB_OUTPUT is required"); @@ -221,6 +266,13 @@ export function writeGitHubOutput( appendFileSync(outputPath, `run_windows=${scope.runWindows}\n`, "utf8"); appendFileSync(outputPath, `run_skills_python=${scope.runSkillsPython}\n`, "utf8"); appendFileSync(outputPath, `run_changed_smoke=${scope.runChangedSmoke}\n`, "utf8"); + appendFileSync(outputPath, `run_node_fast_only=${nodeFastScope.runFastOnly}\n`, "utf8"); + appendFileSync( + outputPath, + `run_node_fast_plugin_contracts=${nodeFastScope.runPluginContracts}\n`, + "utf8", + ); + appendFileSync(outputPath, `run_node_fast_ci_routing=${nodeFastScope.runCiRouting}\n`, "utf8"); appendFileSync( outputPath, `run_fast_install_smoke=${installSmokeScope.runFastInstallSmoke}\n`, @@ -268,6 +320,7 @@ if (isDirectRun()) { detectChangedScope(changedPaths), process.env.GITHUB_OUTPUT, detectInstallSmokeScope(changedPaths), + detectNodeFastScope(changedPaths), ); } catch { writeGitHubOutput(FULL_SCOPE); diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index 9fa5d34c525..7834ad2a39b 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { bundledPluginFile } from "../../test/helpers/bundled-plugin-paths.js"; -const { detectChangedScope, detectInstallSmokeScope, listChangedPaths } = +const { detectChangedScope, detectInstallSmokeScope, detectNodeFastScope, listChangedPaths } = (await import("../../scripts/ci-changed-scope.mjs")) as unknown as { detectChangedScope: (paths: string[]) => { runNode: boolean; @@ -20,6 +20,11 @@ const { detectChangedScope, detectInstallSmokeScope, listChangedPaths } = runFastInstallSmoke: boolean; runFullInstallSmoke: boolean; }; + detectNodeFastScope: (paths: string[]) => { + runFastOnly: boolean; + runPluginContracts: boolean; + runCiRouting: boolean; + }; listChangedPaths: (base: string, head?: string) => string[]; }; @@ -486,6 +491,54 @@ describe("detectChangedScope", () => { }); }); + it("identifies plugin contract helper changes as fast Node-only CI scope", () => { + const bundledCapabilityMetadataPath = [ + "src/plugins/contracts", + "inventory/bundled-capability-metadata.ts", + ].join("/"); + expect( + detectNodeFastScope([ + bundledCapabilityMetadataPath, + "src/plugins/contracts/registry.ts", + "test/helpers/plugins/tts-contract-suites.ts", + "scripts/test-projects.test-support.mjs", + "test/scripts/test-projects.test.ts", + ]), + ).toEqual({ + runFastOnly: true, + runPluginContracts: true, + runCiRouting: false, + }); + }); + + it("identifies CI routing changes as fast Node-only CI scope", () => { + expect( + detectNodeFastScope([ + ".github/workflows/ci.yml", + "scripts/ci-changed-scope.mjs", + "src/scripts/ci-changed-scope.test.ts", + "docs/ci.md", + ]), + ).toEqual({ + runFastOnly: true, + runPluginContracts: false, + runCiRouting: true, + }); + }); + + it("keeps broad source changes on the full Node CI scope", () => { + expect( + detectNodeFastScope([ + "src/plugins/contracts/manifest-loader.ts", + "src/plugins/contracts/registry.ts", + ]), + ).toEqual({ + runFastOnly: false, + runPluginContracts: false, + runCiRouting: false, + }); + }); + it("treats base and head as literal git args", () => { const markerPath = path.join( os.tmpdir(), @@ -527,6 +580,9 @@ describe("detectChangedScope", () => { run_windows: "false", run_skills_python: "false", run_changed_smoke: "false", + run_node_fast_only: "false", + run_node_fast_plugin_contracts: "false", + run_node_fast_ci_routing: "false", run_fast_install_smoke: "false", run_full_install_smoke: "false", run_control_ui_i18n: "false",