diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index bb259b3faedb..6cae366fc28b 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -941,6 +941,7 @@ jobs: live_models_count: ${{ steps.plan.outputs.live_models_count }} live_models_matrix: ${{ steps.plan.outputs.live_models_matrix }} live_models_omitted_json: ${{ steps.plan.outputs.live_models_omitted_json }} + live_image_extensions: ${{ steps.live_image.outputs.live_image_extensions }} steps: - name: Checkout trusted release harness uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -950,6 +951,28 @@ jobs: ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 + - name: Checkout selected live plugin metadata + if: inputs.include_live_suites + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} + path: .release-target + persist-credentials: false + sparse-checkout: | + extensions/*/openclaw.plugin.json + extensions/*/package.json + sparse-checkout-cone-mode: false + + - name: Plan shared live image plugins + id: live_image + if: inputs.include_live_suites + env: + OPENCLAW_LIVE_PROVIDERS: ${{ inputs.live_model_providers }} + # Trusted roster/tooling, selected target metadata; no target code executes here. + run: | + live_image_extensions="$(node scripts/print-live-docker-plugin-selection.mjs .release-target)" + echo "live_image_extensions=$live_image_extensions" >> "$GITHUB_OUTPUT" + - name: Plan release workflow matrices id: plan env: @@ -3387,7 +3410,7 @@ jobs: fi prepare_live_test_image: - needs: validate_selected_ref + needs: [validate_selected_ref, plan_release_workflow_matrices] if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models')) continue-on-error: ${{ inputs.advisory || inputs.live_advisory }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -3421,11 +3444,13 @@ jobs: env: SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} + LIVE_IMAGE_EXTENSIONS: ${{ needs.plan_release_workflow_matrices.outputs.live_image_extensions }} run: | set -euo pipefail repository="${GITHUB_REPOSITORY,,}" - live_image_extensions="matrix,acpx,anthropic" - live_image_tag_suffix="${live_image_extensions//,/-}" + live_image_extensions="$LIVE_IMAGE_EXTENSIONS" + # Keep the tag within Docker's length limit as the provider roster grows. + live_image_tag_suffix="plugins-$(printf '%s' "$live_image_extensions" | sha256sum | cut -c1-12)" if [[ "$SHARED_IMAGE_POLICY" == "no-push-artifact" ]]; then live_image="openclaw-live-test:${SELECTED_SHA}-${live_image_tag_suffix}" else diff --git a/config/knip.config.ts b/config/knip.config.ts index 5f3c5430c265..b87f35eea83d 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -97,6 +97,7 @@ const repositoryScriptEntries = [ "scripts/pr-lib/review-artifacts.mjs!", "scripts/pr-lib/process-group-runner.mjs!", "scripts/pre-commit/filter-staged-files.mjs!", + "scripts/print-live-docker-plugin-selection.mjs!", "scripts/qa-coverage-report.ts!", "scripts/qa-parity-report.ts!", "scripts/resolve-frozen-codex-live-suite.mjs!", diff --git a/scripts/lib/docker-plugin-selection.mjs b/scripts/lib/docker-plugin-selection.mjs index 4c868f7c7ac1..eee3e61b1a81 100644 --- a/scripts/lib/docker-plugin-selection.mjs +++ b/scripts/lib/docker-plugin-selection.mjs @@ -5,13 +5,12 @@ import { collectRootPackageExcludedExtensionDirs } from "./root-package-bundled- const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*$/u; -function readManifestId(pluginDir) { +function readManifest(pluginDir) { const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); if (!fs.existsSync(manifestPath)) { return null; } - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - return typeof manifest.id === "string" && manifest.id.length > 0 ? manifest.id : null; + return JSON.parse(fs.readFileSync(manifestPath, "utf8")); } function collectPluginIdentities(extensionsRoot) { @@ -21,10 +20,13 @@ function collectPluginIdentities(extensionsRoot) { .map((entry) => { const pluginDir = path.join(extensionsRoot, entry.name); const hasPackageJson = fs.existsSync(path.join(pluginDir, "package.json")); - const manifestId = readManifestId(pluginDir); + const manifest = readManifest(pluginDir); + const manifestId = + typeof manifest?.id === "string" && manifest.id.length > 0 ? manifest.id : null; return { dirName: entry.name, manifestId, + providers: manifest?.providers ?? [], known: hasPackageJson || manifestId !== null, }; }) @@ -33,7 +35,7 @@ function collectPluginIdentities(extensionsRoot) { } /** Resolve public Docker selections to the source directories used by build and prune steps. */ -function resolveDockerPluginSelection(params) { +export function resolveDockerPluginSelection(params) { const selection = typeof params.selection === "string" ? params.selection : ""; const selectedIds = new Set( selection @@ -43,6 +45,12 @@ function resolveDockerPluginSelection(params) { ); const plugins = collectPluginIdentities(params.extensionsRoot); const resolvedDirs = new Set(); + const providers = new Set(params.providers ?? []); + for (const plugin of plugins) { + if (plugin.providers.some((provider) => providers.has(provider))) { + resolvedDirs.add(plugin.dirName); + } + } for (const selectedId of selectedIds) { if (!PLUGIN_ID_RE.test(selectedId)) { diff --git a/scripts/print-live-docker-plugin-selection.mjs b/scripts/print-live-docker-plugin-selection.mjs new file mode 100644 index 000000000000..181c8dd96e46 --- /dev/null +++ b/scripts/print-live-docker-plugin-selection.mjs @@ -0,0 +1,24 @@ +// Keeps shared and standalone live images aligned with the release provider roster. +import path from "node:path"; +import { resolveDockerPluginSelection } from "./lib/docker-plugin-selection.mjs"; +import { createReleaseWorkflowMatrixPlan } from "./plan-release-workflow-matrix.mjs"; + +// Standalone shards reuse one image, so a focused invocation must retain the full roster. +const plan = createReleaseWorkflowMatrixPlan({ releaseProfile: "full", includeLiveSuites: true }); +const providers = [ + "deepseek", // Gateway fixture provider outside the direct-model roster. + ...plan.liveModels.matrix.include.map((entry) => entry.providers), + process.env.OPENCLAW_LIVE_PROVIDERS ?? "", + process.env.OPENCLAW_LIVE_GATEWAY_PROVIDERS ?? "", + process.env.OPENCLAW_LIVE_MODELS ?? "", + process.env.OPENCLAW_LIVE_GATEWAY_MODELS ?? "", +].flatMap((value) => value.split(/[\s,]+/u).map((ref) => ref.split("/")[0])); + +// Gateway/CLI fixtures also require these plugin artifacts. +const selection = `matrix,acpx,anthropic,${process.argv[3] ?? ""}`; +const selected = resolveDockerPluginSelection({ + extensionsRoot: path.join(process.argv[2] ?? process.cwd(), "extensions"), + selection, + providers, +}); +console.log(selected.join(",")); diff --git a/scripts/test-live-build-docker.sh b/scripts/test-live-build-docker.sh index 69e14acd25ce..1d7a7d5e5c9a 100755 --- a/scripts/test-live-build-docker.sh +++ b/scripts/test-live-build-docker.sh @@ -22,13 +22,7 @@ if ! [[ "$LIVE_IMAGE_PULL_RETRY_DELAY_SECONDS" =~ ^[0-9]+$ ]]; then exit 2 fi -case " ${DOCKER_BUILD_EXTENSIONS} " in - *" matrix "*) - ;; - *) - DOCKER_BUILD_EXTENSIONS="${DOCKER_BUILD_EXTENSIONS:+${DOCKER_BUILD_EXTENSIONS} }matrix" - ;; -esac +DOCKER_BUILD_EXTENSIONS="$(node "$SCRIPT_ROOT_DIR/scripts/print-live-docker-plugin-selection.mjs" "$ROOT_DIR" "$DOCKER_BUILD_EXTENSIONS")" DOCKER_BUILD_ARGS=() if [[ -n "${DOCKER_BUILD_EXTENSIONS}" ]]; then diff --git a/test/scripts/docker-plugin-selection.test.ts b/test/scripts/docker-plugin-selection.test.ts index 8a9e7b64edd4..657ca5bede3c 100644 --- a/test/scripts/docker-plugin-selection.test.ts +++ b/test/scripts/docker-plugin-selection.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { resolveDockerPluginSelection } from "../../scripts/lib/docker-plugin-selection.mjs"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; const repoRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); @@ -54,6 +55,46 @@ function runSelector( } describe("Docker plugin selection", () => { + it("adds standalone direct and Gateway provider selections to the shared live image", () => { + const result = spawnSync( + process.execPath, + [path.join(repoRoot, "scripts/print-live-docker-plugin-selection.mjs"), repoRoot, "twitch"], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + OPENCLAW_LIVE_PROVIDERS: "ollama", + OPENCLAW_LIVE_GATEWAY_MODELS: "mistral/mistral-large-latest", + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split(",")).toEqual( + expect.arrayContaining(["ollama", "mistral", "twitch"]), + ); + }); + + it("selects provider owners by manifest capability without assuming matching plugin ids", () => { + const extensionsRoot = tempDirs.make("openclaw-docker-provider-selection-"); + writePlugin(extensionsRoot, "provider-source", "provider-plugin"); + writePlugin(extensionsRoot, "other-source", "other-plugin"); + fs.writeFileSync( + path.join(extensionsRoot, "provider-source", "openclaw.plugin.json"), + JSON.stringify({ id: "provider-plugin", providers: ["api-provider", "portal-provider"] }), + ); + + expect( + resolveDockerPluginSelection({ + extensionsRoot, + selection: "other-plugin,provider-plugin", + providers: ["portal-provider", "custom-unregistered-provider"], + }), + ).toEqual(["other-source", "provider-source"]); + expect(resolveDockerPluginSelection({ extensionsRoot, providers: ["api-provider"] })).toEqual([ + "provider-source", + ]); + }); + it("includes required core-bundled dependencies without changing optional plugin selections", () => { const fixtureRoot = tempDirs.make("openclaw-docker-required-bundled-plugins-"); const extensionsRoot = path.join(fixtureRoot, "extensions"); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 29335f443c02..94f5e288faea 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -4355,7 +4355,6 @@ describe("package artifact reuse", () => { expect(workflow).toContain("OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key"); expect(workflow).toContain("suite_id: live-cli-cache-docker"); expect(workflow).toContain("OPENCLAW_LIVE_CLI_BACKEND_CACHE_PROBE=1"); - expect(workflow).toContain('live_image_extensions="matrix,acpx,anthropic"'); expect(workflow).not.toContain("OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1"); expect(workflow).not.toContain('service_tier=\\"fast\\"'); expect(workflow).not.toContain("OPENCLAW_LIVE_CLI_BACKEND_ARGS="); diff --git a/test/scripts/release-workflow-matrix-plan.test.ts b/test/scripts/release-workflow-matrix-plan.test.ts index d36786de006d..cecf7fd89aae 100644 --- a/test/scripts/release-workflow-matrix-plan.test.ts +++ b/test/scripts/release-workflow-matrix-plan.test.ts @@ -1,9 +1,15 @@ // Release Workflow Matrix Plan tests cover release workflow matrix plan script behavior. -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync } from "node:fs"; +import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { parse } from "yaml"; +import { collectBundledPluginBuildEntries } from "../../scripts/lib/bundled-plugin-build-entries.mjs"; import { createReleaseWorkflowMatrixPlan } from "../../scripts/plan-release-workflow-matrix.mjs"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); function workflow(): WorkflowDocument { return parse( @@ -160,6 +166,96 @@ function staticProfileMatrixJobs() { } describe("scripts/plan-release-workflow-matrix.mjs", () => { + it("builds provider owners used by every direct and Gateway Docker live lane", () => { + const definition = workflow(); + const outputDir = tempDirs.make("openclaw-live-image-selection-"); + const outputPath = path.join(outputDir, "outputs"); + symlinkSync(path.resolve("scripts"), path.join(outputDir, "scripts"), "dir"); + mkdirSync(path.join(outputDir, ".release-target")); + symlinkSync( + path.resolve("extensions"), + path.join(outputDir, ".release-target/extensions"), + "dir", + ); + const env = { + PATH: process.env.PATH, + GITHUB_REPOSITORY: "openclaw/openclaw", + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: path.join(outputDir, "summary"), + SELECTED_SHA: "a".repeat(40), + SHARED_IMAGE_POLICY: "no-push-artifact", + }; + const planner = expectDefined( + requiredJob(definition, "plan_release_workflow_matrices").steps.find( + (entry) => entry.name === "Plan shared live image plugins", + ), + "live image planner step", + ); + const planned = spawnSync("bash", ["-c", expectDefined(planner.run, "planner command")], { + cwd: outputDir, + encoding: "utf8", + env, + }); + expect(planned.status, planned.stderr).toBe(0); + const selected = readFileSync(outputPath, "utf8").trim().split("=")[1]; + const step = expectDefined( + requiredJob(definition, "prepare_live_test_image").steps.find( + (entry) => entry.name === "Resolve shared live-test image tag", + ), + "live image selection step", + ); + const result = spawnSync("bash", ["-c", expectDefined(step.run, "selection command")], { + encoding: "utf8", + env: { + ...env, + LIVE_IMAGE_EXTENSIONS: selected, + }, + }); + expect(result.status, result.stderr).toBe(0); + const outputs = Object.fromEntries( + readFileSync(outputPath, "utf8") + .trim() + .split("\n") + .map((line) => line.split("=")), + ); + expect(outputs.live_image?.split(":")[1]?.length).toBeLessThanOrEqual(128); + const builtIds = new Set( + collectBundledPluginBuildEntries({ + env: { OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS: outputs.live_image_extensions }, + }).map((entry: { id: string }) => entry.id), + ); + const plan = createReleaseWorkflowMatrixPlan({ + releaseProfile: "full", + includeLiveSuites: true, + }); + const providers = new Set( + plan.liveModels.matrix.include.map((entry: MatrixEntry) => entry.providers), + ); + for (const entry of requiredJob(definition, "validate_live_docker_provider_suites").strategy + .matrix.include) { + for (const match of JSON.stringify(entry).matchAll( + /OPENCLAW_LIVE_GATEWAY_PROVIDERS=([^\s"]+)/gu, + )) { + for (const provider of expectDefined(match[1], "Gateway provider selection").split(",")) { + providers.add(provider); + } + } + } + const manifests = readdirSync("extensions").flatMap((id) => { + const manifestPath = path.join("extensions", id, "openclaw.plugin.json"); + return existsSync(manifestPath) + ? [{ id, manifest: JSON.parse(readFileSync(manifestPath, "utf8")) }] + : []; + }); + for (const provider of providers) { + const owners = manifests.filter(({ manifest }) => manifest.providers?.includes(provider)); + expect( + owners.some(({ id }) => builtIds.has(id)), + `compiled owner for ${provider}`, + ).toBe(true); + } + }); + it("declares every job input for both workflow entry points", () => { const definition = workflow(); const referencedInputs = new Set(); @@ -349,6 +445,22 @@ describe("scripts/plan-release-workflow-matrix.mjs", () => { expect(planner.outputs.docker_e2e_matrix).toBe("${{ steps.plan.outputs.docker_e2e_matrix }}"); expect(planner.outputs.live_models_matrix).toBe("${{ steps.plan.outputs.live_models_matrix }}"); + expect(planner.outputs.live_image_extensions).toBe( + "${{ steps.live_image.outputs.live_image_extensions }}", + ); + const metadataCheckout = expectDefined( + planner.steps.find((step) => step.name === "Checkout selected live plugin metadata"), + "selected target metadata checkout", + ); + expect(metadataCheckout.with?.ref).toBe( + "${{ needs.validate_selected_ref.outputs.selected_sha }}", + ); + const liveImage = requiredJob(workflow(), "prepare_live_test_image"); + expect(liveImage.needs).toContain("plan_release_workflow_matrices"); + expect( + liveImage.steps.find((step) => step.name === "Resolve shared live-test image tag")?.env + ?.LIVE_IMAGE_EXTENSIONS, + ).toBe("${{ needs.plan_release_workflow_matrices.outputs.live_image_extensions }}"); expect(dockerE2e.needs).toContain("plan_release_workflow_matrices"); expect(liveModels.needs).toContain("plan_release_workflow_matrices"); expect(dockerE2e.strategy.matrix).toBe(