mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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(","));
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user