fix(ci): include live provider plugins in shared Docker images (#132702)

* fix(ci): include live provider plugins in shared Docker images

* fix(ci): register the live Docker selector entrypoint
This commit is contained in:
Peter Steinberger
2026-08-29 10:51:08 -07:00
committed by GitHub
parent 2cf09d07d3
commit e2ca099d46
8 changed files with 222 additions and 18 deletions
@@ -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
+1
View File
@@ -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!",
+13 -5
View File
@@ -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)) {
@@ -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(","));
+1 -7
View File
@@ -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
@@ -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");
@@ -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=");
@@ -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<string>();
@@ -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(