name: Plugin Prerelease on: workflow_dispatch: inputs: target_ref: description: Branch, tag, or full commit SHA to validate required: false default: main type: string expected_sha: description: Optional full commit SHA that target_ref must resolve to required: false default: "" type: string full_release_validation: description: Enable release-only Docker prerelease lanes from Full Release Validation required: false default: false type: boolean permissions: contents: read concurrency: group: plugin-prerelease-${{ inputs.target_ref }} cancel-in-progress: ${{ inputs.target_ref == 'main' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: preflight: name: Build plugin prerelease plan runs-on: ubuntu-24.04 timeout-minutes: 15 outputs: checkout_revision: ${{ steps.manifest.outputs.checkout_revision }} run_plugin_prerelease_suite: ${{ steps.manifest.outputs.run_plugin_prerelease_suite }} run_plugin_prerelease_static: ${{ steps.manifest.outputs.run_plugin_prerelease_static }} plugin_prerelease_static_matrix: ${{ steps.manifest.outputs.plugin_prerelease_static_matrix }} run_plugin_prerelease_node: ${{ steps.manifest.outputs.run_plugin_prerelease_node }} plugin_prerelease_node_matrix: ${{ steps.manifest.outputs.plugin_prerelease_node_matrix }} run_plugin_prerelease_extensions: ${{ steps.manifest.outputs.run_plugin_prerelease_extensions }} plugin_prerelease_extension_matrix: ${{ steps.manifest.outputs.plugin_prerelease_extension_matrix }} run_plugin_prerelease_docker: ${{ steps.manifest.outputs.run_plugin_prerelease_docker }} plugin_prerelease_docker_lanes: ${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }} steps: - name: Checkout target uses: actions/checkout@v6 with: ref: ${{ inputs.target_ref }} fetch-depth: 1 fetch-tags: false persist-credentials: false submodules: false - name: Build plugin prerelease manifest id: manifest env: EXPECTED_SHA: ${{ inputs.expected_sha }} FULL_RELEASE_VALIDATION: ${{ inputs.full_release_validation && 'true' || 'false' }} run: | node --input-type=module <<'EOF' import { appendFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; const createMatrix = (include) => ({ include }); const outputPath = process.env.GITHUB_OUTPUT; const checkoutRevision = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", }).trim(); const expectedSha = (process.env.EXPECTED_SHA ?? "").trim(); const fullReleaseValidation = process.env.FULL_RELEASE_VALIDATION === "true"; if (expectedSha && expectedSha !== checkoutRevision) { console.error( `target_ref resolved to ${checkoutRevision}, expected ${expectedSha}`, ); process.exit(1); } let pluginPrereleasePlan = { staticChecks: [], dockerLanes: [] }; let extensionShards = []; let nodeShards = []; try { const { assertPluginPrereleaseTestPlanComplete } = await import( "./scripts/lib/plugin-prerelease-test-plan.mjs" ); pluginPrereleasePlan = assertPluginPrereleaseTestPlanComplete(); } catch (error) { const errorCode = error && typeof error === "object" && "code" in error ? error.code : ""; const moduleUrl = error && typeof error === "object" && "url" in error ? String(error.url) : ""; if ( errorCode === "ERR_MODULE_NOT_FOUND" && moduleUrl.endsWith("/scripts/lib/plugin-prerelease-test-plan.mjs") ) { console.warn( "Plugin prerelease plan unavailable in target ref; skipping static and Docker plugin prerelease lanes.", ); } else { throw error; } } try { const { createExtensionTestShards, DEFAULT_EXTENSION_TEST_SHARD_COUNT } = await import( "./scripts/lib/extension-test-plan.mjs" ); extensionShards = createExtensionTestShards({ shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT, }).map((shard) => ({ check_name: shard.checkName, extensions_csv: shard.extensionIds.join(","), runner: [0, 1, 2, 3].includes(shard.index) ? "blacksmith-8vcpu-ubuntu-2404" : "blacksmith-4vcpu-ubuntu-2404", shard_index: shard.index + 1, task: "extensions-batch", })); } catch (error) { const errorCode = error && typeof error === "object" && "code" in error ? error.code : ""; const moduleUrl = error && typeof error === "object" && "url" in error ? String(error.url) : ""; if ( errorCode === "ERR_MODULE_NOT_FOUND" && moduleUrl.endsWith("/scripts/lib/extension-test-plan.mjs") ) { console.warn( "Extension test plan unavailable in target ref; skipping extension prerelease shards.", ); } else { throw error; } } try { const { createNodeTestShards } = await import("./scripts/lib/ci-node-test-plan.mjs"); nodeShards = createNodeTestShards({ includeReleaseOnlyPluginShards: true, }) .filter((shard) => shard.shardName === "agentic-plugins") .map((shard) => ({ check_name: shard.checkName, runtime: "node", task: "test-shard", shard_name: shard.shardName, configs: shard.configs, includePatterns: shard.includePatterns, runner: shard.runner, })); } catch (error) { const errorCode = error && typeof error === "object" && "code" in error ? error.code : ""; const moduleUrl = error && typeof error === "object" && "url" in error ? String(error.url) : ""; if ( errorCode === "ERR_MODULE_NOT_FOUND" && moduleUrl.endsWith("/scripts/lib/ci-node-test-plan.mjs") ) { console.warn( "Node test plan unavailable in target ref; skipping release-only plugin Node shard.", ); } else { throw error; } } const staticChecks = pluginPrereleasePlan.staticChecks.map((check) => ({ check_name: check.checkName, command: check.command, task: check.check, })); const dockerLanes = pluginPrereleasePlan.dockerLanes; const runStatic = staticChecks.length > 0; const runNode = nodeShards.length > 0; const runExtensions = extensionShards.length > 0; const runDocker = fullReleaseValidation && dockerLanes.length > 0; const runSuite = runStatic || runNode || runExtensions || runDocker; const manifest = { checkout_revision: checkoutRevision, run_plugin_prerelease_suite: runSuite, run_plugin_prerelease_static: runStatic, plugin_prerelease_static_matrix: createMatrix(staticChecks), run_plugin_prerelease_node: runNode, plugin_prerelease_node_matrix: createMatrix(nodeShards), run_plugin_prerelease_extensions: runExtensions, plugin_prerelease_extension_matrix: createMatrix(extensionShards), run_plugin_prerelease_docker: runDocker, plugin_prerelease_docker_lanes: dockerLanes.join(" "), }; for (const [key, value] of Object.entries(manifest)) { appendFileSync( outputPath, `${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`, "utf8", ); } EOF plugin-prerelease-static-shard: permissions: contents: read name: ${{ matrix.check_name }} needs: [preflight] if: needs.preflight.outputs.run_plugin_prerelease_static == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 45 strategy: fail-fast: false matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_static_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false submodules: false - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" - name: Run plugin prerelease static shard env: PLUGIN_PRERELEASE_COMMAND: ${{ matrix.command }} PLUGIN_PRERELEASE_TASK: ${{ matrix.task }} shell: bash run: | set -euo pipefail echo "Running ${PLUGIN_PRERELEASE_TASK}: ${PLUGIN_PRERELEASE_COMMAND}" bash -c "$PLUGIN_PRERELEASE_COMMAND" plugin-prerelease-node-shard: permissions: contents: read name: ${{ matrix.check_name }} needs: [preflight] if: needs.preflight.outputs.run_plugin_prerelease_node == 'true' runs-on: ${{ matrix.runner || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_node_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false submodules: false - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" - name: Configure Node test resources run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV" - name: Run release-only plugin Node shard env: NODE_OPTIONS: --max-old-space-size=8192 OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }} OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }} OPENCLAW_TEST_PROJECTS_PARALLEL: "2" shell: bash run: | set -euo pipefail node --input-type=module <<'EOF' import { spawnSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"); if (!Array.isArray(configs) || configs.length === 0) { console.error("Missing node test shard configs"); process.exit(1); } const includePatterns = JSON.parse( process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null", ); const childEnv = { ...process.env }; if (Array.isArray(includePatterns) && includePatterns.length > 0) { const includeFile = join( process.env.RUNNER_TEMP ?? ".", `node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`, ); writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8"); childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile; } const result = spawnSync( "pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], { env: childEnv, stdio: "inherit", }, ); process.exit(result.status ?? 1); EOF plugin-prerelease-extension-shard: permissions: contents: read name: ${{ matrix.check_name }} needs: [preflight] if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true' runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_extension_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false submodules: false - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" - name: Run extension shard env: NODE_OPTIONS: --max-old-space-size=8192 OPENCLAW_EXTENSION_BATCH_PARALLEL: 2 OPENCLAW_VITEST_MAX_WORKERS: 1 OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }} run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH" plugin-prerelease-inspector: permissions: contents: read name: plugin-prerelease-inspector needs: [preflight] if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true' continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false submodules: false - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" - name: Run plugin inspector advisory sweep env: OPENCLAW_PLUGIN_INSPECTOR_VERSION: "0.3.10" OPENCLAW_PLUGIN_INSPECTOR_ROOT: .artifacts/plugin-inspector shell: bash run: | set -euo pipefail mkdir -p "$OPENCLAW_PLUGIN_INSPECTOR_ROOT" set +e node --input-type=module <<'EOF' import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; const artifactRoot = process.env.OPENCLAW_PLUGIN_INSPECTOR_ROOT; if (!artifactRoot) { throw new Error("OPENCLAW_PLUGIN_INSPECTOR_ROOT is required"); } const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8")); const inferSeams = (pluginManifest, packageJson) => { const contracts = Object.keys(pluginManifest?.contracts ?? {}); if (contracts.includes("tools")) { return ["dynamic-tool"]; } const openclawPackage = packageJson?.openclaw ?? {}; if (openclawPackage.extensions || openclawPackage.runtimeExtensions) { return ["plugin-runtime"]; } return ["plugin-metadata"]; }; const extensionRoot = path.resolve("extensions"); const fixtures = []; for (const entry of await readdir(extensionRoot, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } const relativePath = `extensions/${entry.name}`; const packagePath = path.join(extensionRoot, entry.name, "package.json"); const manifestPath = path.join(extensionRoot, entry.name, "openclaw.plugin.json"); if (!existsSync(packagePath) || !existsSync(manifestPath)) { continue; } const packageJson = await readJson(packagePath); const pluginManifest = await readJson(manifestPath); fixtures.push({ id: entry.name, name: pluginManifest.name ?? packageJson.name ?? entry.name, path: relativePath, priority: "high", repo: "local", seams: inferSeams(pluginManifest, packageJson), why: "bundled OpenClaw plugin prerelease advisory fixture", }); } fixtures.sort((left, right) => left.id.localeCompare(right.id)); if (fixtures.length === 0) { throw new Error("No bundled plugin fixtures found under extensions/"); } await mkdir(artifactRoot, { recursive: true }); const config = `${JSON.stringify( { version: 1, submoduleRoot: ".", openclaw: { defaultCheckoutPath: ".", }, fixtures, }, null, 2, )}\n`; await writeFile("plugin-inspector.config.json", config, "utf8"); await writeFile(path.join(artifactRoot, "plugin-inspector.config.json"), config, "utf8"); EOF config_status=$? set -e echo "$config_status" > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/config-exit-code.txt" if [ "$config_status" -eq 0 ]; then set +e npm exec --yes "@openclaw/plugin-inspector@${OPENCLAW_PLUGIN_INSPECTOR_VERSION}" -- ci \ --config plugin-inspector.config.json \ --openclaw "$PWD" \ --out "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/reports" \ --json \ > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stdout.json" \ 2> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stderr.log" inspector_status=$? set -e else inspector_status=127 echo "Skipped plugin-inspector because config generation failed." \ > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stderr.log" fi echo "$inspector_status" > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/exit-code.txt" node --input-type=module <<'EOF' import { existsSync } from "node:fs"; import { appendFile, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; const artifactRoot = process.env.OPENCLAW_PLUGIN_INSPECTOR_ROOT; const summaryPath = path.join(artifactRoot, "reports/plugin-inspector-ci-summary.json"); const markdownPath = path.join(artifactRoot, "reports/plugin-inspector-ci-summary.md"); const configExitCode = (await readFile(path.join(artifactRoot, "config-exit-code.txt"), "utf8")).trim(); const exitCode = (await readFile(path.join(artifactRoot, "exit-code.txt"), "utf8")).trim(); const lines = [ "## Plugin Inspector Advisory", "", `Inspector: @openclaw/plugin-inspector@${process.env.OPENCLAW_PLUGIN_INSPECTOR_VERSION}`, `Config exit code: ${configExitCode}`, `Exit code: ${exitCode}`, ]; if (existsSync(summaryPath)) { const summary = JSON.parse(await readFile(summaryPath, "utf8")); lines.push( `Status: ${String(summary.status ?? "unknown").toUpperCase()}`, "", "| Metric | Count |", "| --- | ---: |", `| Hard breakages | ${summary.summary?.breakages ?? 0} |`, `| Issues | ${summary.summary?.issues ?? 0} |`, `| P0 issues | ${summary.summary?.p0Issues ?? 0} |`, `| P1 issues | ${summary.summary?.p1Issues ?? 0} |`, `| Compat gaps | ${summary.summary?.compatGaps ?? 0} |`, `| Inspector gaps | ${summary.summary?.inspectorGaps ?? 0} |`, "", "This job is informational; Plugin Prerelease blocking status is unchanged.", ); await writeFile(path.join(artifactRoot, "advisory-summary.md"), `${lines.join("\n")}\n`, "utf8"); if (existsSync(markdownPath)) { lines.push("", "### Full inspector summary", ""); lines.push(await readFile(markdownPath, "utf8")); } } else { lines.push("", "No plugin-inspector CI summary was produced.", ""); lines.push("This job is informational; inspect the uploaded stdout/stderr artifacts."); await writeFile(path.join(artifactRoot, "advisory-summary.md"), `${lines.join("\n")}\n`, "utf8"); } await appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`, "utf8"); EOF - name: Upload plugin inspector advisory artifacts if: always() uses: actions/upload-artifact@v7 with: name: plugin-inspector-advisory path: .artifacts/plugin-inspector/** if-no-files-found: warn plugin-prerelease-docker-suite: name: plugin-prerelease-docker-suite needs: [preflight] if: ${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }} permissions: actions: read contents: read packages: write pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: ref: ${{ needs.preflight.outputs.checkout_revision }} include_repo_e2e: false include_release_path_suites: false include_openwebui: false docker_lanes: ${{ needs.preflight.outputs.plugin_prerelease_docker_lanes }} targeted_docker_lane_group_size: 4 include_live_suites: false live_models_only: false plugin-prerelease-suite: permissions: contents: read name: plugin-prerelease-suite needs: - preflight - plugin-prerelease-static-shard - plugin-prerelease-node-shard - plugin-prerelease-extension-shard - plugin-prerelease-inspector - plugin-prerelease-docker-suite if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_prerelease_suite == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Verify plugin prerelease suite env: RUN_STATIC: ${{ needs.preflight.outputs.run_plugin_prerelease_static }} RUN_NODE: ${{ needs.preflight.outputs.run_plugin_prerelease_node }} RUN_EXTENSIONS: ${{ needs.preflight.outputs.run_plugin_prerelease_extensions }} RUN_DOCKER: ${{ needs.preflight.outputs.run_plugin_prerelease_docker }} STATIC_RESULT: ${{ needs.plugin-prerelease-static-shard.result }} NODE_RESULT: ${{ needs.plugin-prerelease-node-shard.result }} EXTENSIONS_RESULT: ${{ needs.plugin-prerelease-extension-shard.result }} INSPECTOR_RESULT: ${{ needs.plugin-prerelease-inspector.result }} DOCKER_RESULT: ${{ needs.plugin-prerelease-docker-suite.result }} shell: bash run: | set -euo pipefail failed=0 check_required() { local name="$1" local required="$2" local status="$3" if [ "$required" != "true" ]; then return 0 fi if [ "$status" != "success" ]; then echo "::error::${name} ended with ${status}" failed=1 fi } check_required "plugin-prerelease-static" "$RUN_STATIC" "$STATIC_RESULT" check_required "plugin-prerelease-node" "$RUN_NODE" "$NODE_RESULT" check_required "plugin-prerelease-extensions" "$RUN_EXTENSIONS" "$EXTENSIONS_RESULT" check_required "plugin-prerelease-docker" "$RUN_DOCKER" "$DOCKER_RESULT" echo "plugin-prerelease-inspector advisory result: ${INSPECTOR_RESULT}" exit "$failed"