openclaw/test/scripts/plugin-prerelease-test-plan.test.ts
Peter Steinberger 7cbaa6b36f
refactor(scripts): deslop shared tooling flows (#162824)
Consolidate script lifecycle and helper ownership while preserving CLI, generated-output, cleanup, and guard contracts. Reject inherited object keys in model-matrix option admission with the existing unknown-argument diagnostic.

Validation: 90 full sibling suites; original-fails parser regression; byte-identical native outputs; changed checks; SDK surface and API comparison; both zero-cycle checks; independent isolated review.
2026-10-01 21:30:19 +00:00

1587 lines
70 KiB
TypeScript

// Plugin Prerelease Test Plan tests cover plugin prerelease test plan script behavior.
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join, matchesGlob, resolve } from "node:path";
import { runInNewContext } from "node:vm";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
import { listAvailableExtensionIds } from "../../scripts/lib/changed-extensions.mts";
import { findLaneByName } from "../../scripts/lib/docker-e2e-plan.mts";
import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS } from "../../scripts/lib/docker-e2e-scenarios.mts";
import {
resolveExtensionTestPlan,
resolveExtensionTestConfig,
} from "../../scripts/lib/extension-test-plan.mts";
import {
assertPluginPrereleaseTestPlanComplete,
createPluginPrereleaseTestPlan,
resolvePluginPrereleaseExtensionRuntime,
} from "../../scripts/lib/plugin-prerelease-test-plan.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import { evaluateWorkflowExpression, evaluateWorkflowRunner } from "./ci-workflow.test-support.js";
const CHECKOUT_V6 = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1";
const UPLOAD_ARTIFACT_V7 = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
type WorkflowStep = {
id?: string;
env?: Record<string, string>;
name?: string;
run?: string;
uses?: string;
with?: Record<string, unknown>;
};
function readCiWorkflow() {
return parse(readFileSync(".github/workflows/ci.yml", "utf8"));
}
function readFullReleaseValidationWorkflow() {
return parse(readFileSync(".github/workflows/full-release-validation.yml", "utf8"));
}
function readPluginPrereleaseWorkflow() {
return parse(readFileSync(".github/workflows/plugin-prerelease.yml", "utf8"));
}
function getDockerLane(name: string) {
const lane = findLaneByName(name);
if (!lane) {
throw new Error(`Missing Docker E2E lane ${name}`);
}
return lane;
}
function pluginCandidateArtifactJson(selectedSha = "a".repeat(40)) {
return JSON.stringify({
packageArtifactName: "docker-e2e-package-123-1",
packageArtifactId: "456",
packageArtifactDigest: "b".repeat(64),
packageArtifactRunId: "123",
packageArtifactRunAttempt: "1",
packageFileName: "openclaw-current.tgz",
packageSourceSha: selectedSha,
packageSha256: "c".repeat(64),
packageVersion: "2026.8.1",
imageArtifactName: "docker-e2e-shared-images-123-1",
imageArtifactId: "789",
imageArtifactDigest: "d".repeat(64),
imageArtifactRunId: "123",
imageArtifactRunAttempt: "1",
imageArchiveSha256: "e".repeat(64),
});
}
function runPluginPhaseValidation(params: {
candidateArtifactJson?: string;
expectedSha?: string;
fullReleaseValidation?: boolean;
phase: string;
}) {
const workflow = readPluginPrereleaseWorkflow();
const step = workflow.jobs.preflight.steps.find(
(candidate: WorkflowStep) => candidate.name === "Validate phase inputs",
);
if (!step?.run) {
throw new Error("Missing plugin prerelease phase validation step");
}
return spawnSync("bash", ["-c", step.run], {
encoding: "utf8",
env: {
CANDIDATE_ARTIFACT_JSON: params.candidateArtifactJson ?? "",
EXPECTED_SHA: params.expectedSha ?? "",
FULL_RELEASE_VALIDATION: String(params.fullReleaseValidation ?? true),
PATH: process.env.PATH,
PHASE: params.phase,
},
});
}
function runPluginManifest(
phase: "all" | "candidate" | "independent",
eventName = "workflow_dispatch",
) {
const workflow = readPluginPrereleaseWorkflow();
const step = workflow.jobs.preflight.steps.find(
(candidate: WorkflowStep) => candidate.name === "Build plugin prerelease manifest",
);
if (!step?.run) {
throw new Error("Missing plugin prerelease manifest step");
}
const root = tempDirs.make("openclaw-plugin-prerelease-phase-");
const outputPath = join(root, "github-output");
const result = spawnSync("bash", ["-c", step.run], {
encoding: "utf8",
env: {
FULL_RELEASE_VALIDATION: eventName === "schedule" ? "false" : "true",
GITHUB_EVENT_NAME: eventName,
GITHUB_OUTPUT: outputPath,
PATH: process.env.PATH,
PHASE: phase,
},
});
return {
output: existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "",
result,
};
}
function runPluginSummary(params: {
docker: string;
extensions: string;
inspector?: string;
node: string;
runDocker: boolean;
runExtensions: boolean;
runNode: boolean;
runNpmSecurity: boolean;
runStatic: boolean;
static: string;
}) {
const workflow = readPluginPrereleaseWorkflow();
const step = workflow.jobs["plugin-prerelease-suite"].steps.find(
(candidate: WorkflowStep) => candidate.name === "Verify plugin prerelease suite",
);
if (!step?.run) {
throw new Error("Missing plugin prerelease summary step");
}
return spawnSync("bash", ["-c", step.run], {
encoding: "utf8",
env: {
DOCKER_RESULT: params.docker,
EXTENSIONS_RESULT: params.extensions,
INSPECTOR_RESULT: params.inspector ?? "skipped",
NODE_RESULT: params.node,
PATH: process.env.PATH,
RUN_DOCKER: String(params.runDocker),
RUN_EXTENSIONS: String(params.runExtensions),
RUN_NODE: String(params.runNode),
RUN_NPM_SECURITY: String(params.runNpmSecurity),
RUN_STATIC: String(params.runStatic),
SECURITY_RESULT: "success",
STATIC_RESULT: params.static,
},
});
}
describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
it.each([
{
name: "ordinary plugin validation",
fullReleaseValidation: false,
memory: true,
vitestArgs: [],
requiresBun: false,
},
{
name: "full release memory and database-worker groups",
fullReleaseValidation: true,
memory: true,
vitestArgs: [],
requiresBun: true,
},
{
name: "full release with an exact exclusion",
fullReleaseValidation: true,
memory: true,
vitestArgs: ["--exclude=extensions/memory-lancedb/config.test.ts"],
requiresBun: true,
},
{
name: "full release with an explicit report",
fullReleaseValidation: true,
memory: true,
vitestArgs: ["--reporter=json", "--outputFile=report.json"],
requiresBun: false,
},
{
name: "full release database-worker groups",
fullReleaseValidation: true,
memory: false,
vitestArgs: [],
requiresBun: false,
},
])(
"selects runtime setup for $name",
async ({ fullReleaseValidation, memory, vitestArgs, requiresBun }) => {
const planGroups = [
{
config: "test/vitest/vitest.extension-database-workers.config.ts",
roots: ["extensions/memory-lancedb/index.test.ts"],
},
...(memory
? [
{
config: "test/vitest/vitest.extension-memory.config.ts",
roots: ["extensions/memory-lancedb"],
},
]
: []),
];
expect(
await resolvePluginPrereleaseExtensionRuntime({
planGroups,
fullReleaseValidation,
vitestArgs,
}),
).toEqual({ test_runtime_policy: requiresBun ? "dual" : "node", requires_bun: requiresBun });
},
);
it("runs the package and Docker product lanes through the existing scheduler", () => {
const plan = assertPluginPrereleaseTestPlanComplete();
expect(plan.dockerLanes).toEqual([
"npm-onboard-channel-agent",
"npm-onboard-discord-candidate-channel-agent",
"npm-onboard-slack-candidate-channel-agent",
"doctor-switch",
"update-channel-switch",
"plugins-offline",
"plugins",
"kitchen-sink-plugin",
"kitchen-sink-rpc",
"plugin-update",
"config-reload",
"gateway-network",
"mcp-channels",
"cron-mcp-cleanup",
...Array.from(
{ length: BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS },
(_, index) => `bundled-plugin-install-uninstall-${index}`,
),
]);
for (const lane of plan.dockerLanes) {
expect(getDockerLane(lane).name).toBe(lane);
}
const candidateLane = getDockerLane("npm-onboard-discord-candidate-channel-agent");
expect(candidateLane.command).toContain("OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR");
expect(candidateLane.command).toContain(
'OPENCLAW_LIVE_DOCKER_REPO_ROOT="${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}"',
);
});
it("keeps live-ish coverage outside provider-backed Docker lanes", () => {
const plan = createPluginPrereleaseTestPlan();
expect(plan.dockerLanes).not.toContain("openai-web-search-minimal");
expect(plan.dockerLanes.some((lane) => lane.startsWith("live-"))).toBe(false);
expect(plan.staticChecks[2]).toEqual({
check: "live-ish-availability",
checkName: "checks-plugin-prerelease-live-ish-availability",
command: "node --import tsx scripts/plugin-prerelease-liveish-matrix.mts",
surfaces: ["live-ish-availability"],
});
});
it("keeps SDK/package boundary checks inside the plugin prerelease suite", () => {
const plan = createPluginPrereleaseTestPlan();
expect(plan.staticChecks.map((check) => check.checkName)).toEqual([
"checks-plugin-prerelease-package-boundary-compile",
"checks-plugin-prerelease-package-boundary-canary",
"checks-plugin-prerelease-live-ish-availability",
]);
});
it("uses kitchen-sink npm and ClawHub scenarios as the registry install canary", () => {
const lane = getDockerLane("kitchen-sink-plugin");
const script = readFileSync("scripts/e2e/kitchen-sink-plugin-docker.sh", "utf8");
const sweepScript = readFileSync("scripts/e2e/lib/kitchen-sink-plugin/sweep.sh", "utf8");
const assertionsScript = readFileSync(
"scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs",
"utf8",
);
expect(lane).toEqual({
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:kitchen-sink-plugin",
e2eImageKind: "functional",
live: false,
name: "kitchen-sink-plugin",
resources: ["npm"],
stateScenario: "empty",
weight: 3,
});
expect(script).toContain("npm:@openclaw/kitchen-sink@latest");
expect(script).toContain("npm-latest-conformance");
expect(script).toContain("npm-latest-adversarial");
expect(script).toContain("npm:@openclaw/kitchen-sink@beta");
expect(script).toContain("clawhub:@openclaw/kitchen-sink@latest");
expect(script).toContain("clawhub:@openclaw/kitchen-sink@beta");
expect(script).toContain("OPENCLAW_KITCHEN_SINK_PLUGIN_MAX_MEMORY_MIB");
expect(script).toContain(
"npm-to-clawhub|clawhub:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic||${KITCHEN_SINK_NPM_SPEC}",
);
expect(script).toContain("scripts/e2e/lib/kitchen-sink-plugin/sweep.sh");
expect(sweepScript).toContain('plugins install "$KITCHEN_SINK_SPEC" --force');
expect(sweepScript).toContain('plugins install "$KITCHEN_SINK_PREINSTALL_SPEC" --force');
expect(sweepScript).toContain("assert-cutover-preinstalled");
expect(sweepScript).toContain('install_args+=("--force")');
expect(sweepScript).toContain("KITCHEN_SINK_PERSONALITY");
expect(sweepScript).toContain("OPENCLAW_KITCHEN_SINK_PERSONALITY");
expect(sweepScript).toContain('plugins uninstall "$KITCHEN_SINK_SPEC" --force');
const successScenario = sweepScript.slice(
sweepScript.indexOf("run_success_scenario()"),
sweepScript.indexOf("run_failure_scenario()"),
);
const installIndex = successScenario.indexOf('plugins install "${install_args[@]}" --force');
const configureIndex = successScenario.indexOf("assertions.mjs configure-runtime");
const enableIndex = successScenario.indexOf('plugins enable "$KITCHEN_SINK_ID"');
expect(installIndex).toBeGreaterThanOrEqual(0);
expect(configureIndex).toBeGreaterThan(installIndex);
expect(enableIndex).toBeGreaterThan(configureIndex);
expect(successScenario).toContain('plugins inspect "$KITCHEN_SINK_ID" --runtime --json');
expect(successScenario).toContain("plugins inspect --all --runtime --json");
expect(sweepScript).toContain("run_failure_scenario");
expect(assertionsScript).toContain("assertCutoverPreinstalled");
expect(assertionsScript).toContain("record.source !== source");
expect(assertionsScript).toContain("record.clawhubPackage !== packageName");
expect(assertionsScript).toContain("record.artifactKind");
expect(assertionsScript).toContain("assertClawHubExternalInstallContract");
expect(assertionsScript).toContain("expectedErrorMessages");
expect(assertionsScript).toContain(
'const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "adversarial"]);',
);
expect(assertionsScript).toContain("!INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES.has(surfaceMode)");
expect(readFileSync("scripts/e2e/lib/clawhub-fixture-server.cjs", "utf8")).toContain(
'from "openclaw/plugin-sdk/plugin-entry"',
);
expect(readFileSync("scripts/e2e/lib/clawhub-fixture-server.cjs", "utf8")).toContain(
"X-ClawHub-Artifact-Sha256",
);
expect(script).toContain("docker_e2e_sample_stats_until_exit");
expect(script).toContain("scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs");
expect(sweepScript).toContain("assertions.mjs scan-logs");
});
it("keeps kitchen-sink RPC coverage package-backed and resource-guarded", () => {
const lane = getDockerLane("kitchen-sink-rpc");
const script = readFileSync("scripts/e2e/kitchen-sink-rpc-docker.sh", "utf8");
const walkScript = readFileSync("scripts/e2e/kitchen-sink-rpc-walk.mts", "utf8");
expect(lane).toMatchObject({
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:kitchen-sink-rpc",
e2eImageKind: "functional",
live: false,
name: "kitchen-sink-rpc",
resources: ["service", "npm"],
stateScenario: "empty",
timeoutMs: 1_500_000,
weight: 3,
});
expect(script).toContain("OPENCLAW_ENTRY=/app/openclaw.mjs");
expect(script).toContain("OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB");
expect(script).toContain("docker_e2e_sample_stats_until_exit");
expect(script).toContain("scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs");
expect(script).toContain(
"openclaw_e2e_run_script_entrypoint scripts/e2e/kitchen-sink-rpc-walk",
);
expect(walkScript).toContain("commands.list");
expect(walkScript).toContain("tools.invoke");
expect(walkScript).toContain("tts.providers");
expect(walkScript).toContain("plugins.uiDescriptors");
expect(walkScript).toContain("loadCallGatewayModule(options.runner)");
expect(walkScript).toContain("usesBuiltOpenClawEntry(runner)");
expect(walkScript).toContain('"gateway"');
expect(walkScript).toContain('"call"');
expect(walkScript).not.toContain("src/gateway/call.ts");
expect(walkScript).toContain("^call(?:\\.runtime)?");
});
it("keeps the generic plugin Docker lane as an external install contract canary", () => {
const lane = getDockerLane("plugins");
const sweepScript = readFileSync("scripts/e2e/lib/plugins/sweep.sh", "utf8");
const clawhubScript = readFileSync("scripts/e2e/lib/plugins/clawhub.sh", "utf8");
const assertionsScript = readFileSync("scripts/e2e/lib/plugins/assertions.mjs", "utf8");
const fixtureServer = readFileSync("scripts/e2e/lib/clawhub-fixture-server.cjs", "utf8");
const prereleasePlan = createPluginPrereleaseTestPlan();
expect(lane).toEqual({
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins",
e2eImageKind: "functional",
live: false,
name: "plugins",
resources: ["npm", "service"],
stateScenario: "empty",
weight: 6,
});
expect(prereleasePlan.surfaces).toContain("external-install-boundary");
expect(sweepScript).toContain("run_plugins_clawhub_scenario");
expect(clawhubScript).toContain('plugins install "$CLAWHUB_PLUGIN_SPEC"');
expect(assertionsScript).toContain("assertClawHubExternalInstallContract");
expect(fixtureServer).toContain('"is-number": "7.0.0"');
expect(fixtureServer).toContain('openclaw: ">=2026.4.11"');
expect(fixtureServer).toContain("/versions/${fixture.version}/artifact");
});
it("forwards validated frozen-target omissions to the selected shard adapters", () => {
const workflow = readPluginPrereleaseWorkflow();
const preflight = workflow.jobs.preflight;
const nodeStep = workflow.jobs["plugin-prerelease-node-shard"].steps.find(
(step: WorkflowStep) => step.name === "Run release-only plugin Node shard",
);
const extensionStep = workflow.jobs["plugin-prerelease-extension-shard"].steps.find(
(step: WorkflowStep) => step.name === "Run extension shard",
);
expect(preflight.outputs.node_test_exclude_patterns_json).toBe(
"${{ steps.node_test_exclusions.outputs.patterns_json }}",
);
expect(nodeStep.env.NODE_TEST_EXCLUDE_PATTERNS_JSON).toBe(
"${{ needs.preflight.outputs.node_test_exclude_patterns_json }}",
);
expect(extensionStep.env.FRV_TEST_EXCLUDE_PATHS_JSON).toBe(
"${{ needs.preflight.outputs.extension_test_exclude_patterns_json }}",
);
expect(extensionStep.run).not.toContain("extensions/codex/src/app-server/run-attempt.test.ts");
});
it("keeps the trusted security scanner outside the candidate test process", () => {
const workflow = readPluginPrereleaseWorkflow();
const admission = workflow.jobs.resolve_target;
const securityPlan = workflow.jobs["plugin-npm-security-plan"];
const securityScan = workflow.jobs["plugin-npm-security-scan"];
const source = readFileSync(".github/workflows/plugin-prerelease.yml", "utf8");
expect(admission.steps).toEqual([
expect.objectContaining({
uses: CHECKOUT_V6,
with: expect.objectContaining({ ref: "${{ github.sha }}", path: "workflow" }),
}),
expect.objectContaining({
env: {
EXPECTED_SHA:
"${{ github.event_name == 'schedule' && github.sha || inputs.expected_sha }}",
OPENCLAW_REF_REMOTE: "${{ github.server_url }}/${{ github.repository }}.git",
TARGET_REF: "${{ github.event_name == 'schedule' && github.sha || inputs.target_ref }}",
},
run: expect.stringContaining("bash workflow/scripts/github/resolve-openclaw-ref.sh"),
}),
]);
expect(securityPlan.needs).toEqual(["resolve_target"]);
expect(securityPlan.if).toBe("github.event_name != 'schedule' && inputs.phase != 'candidate'");
expect(workflow.jobs["plugin-npm-security-package"]).toBeUndefined();
const securityPlanStepNames = securityPlan.steps.map((step: WorkflowStep) => step.name);
expect(securityPlanStepNames.indexOf("Install trusted scanner dependencies")).toBeLessThan(
securityPlanStepNames.indexOf("Checkout candidate as inert data"),
);
expect(evaluateWorkflowRunner(securityScan["runs-on"])).toBe("ubuntu-24.04");
expect(securityScan).toMatchObject({
name: "plugin-npm-security-scan",
needs: ["resolve_target", "plugin-npm-security-plan"],
permissions: { contents: "read" },
"runs-on": expect.any(String),
"timeout-minutes": 45,
});
const securityScanStepNames = securityScan.steps.map((step: WorkflowStep) => step.name);
expect(securityScanStepNames.indexOf("Install trusted scanner dependencies")).toBeLessThan(
securityScanStepNames.indexOf("Checkout candidate as inert package input"),
);
const packStep = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Pack supplemental inert plugin inputs",
);
expect(packStep?.env?.EXPECTED_PACKAGES_JSON).toBe(
"${{ needs.plugin-npm-security-plan.outputs.packages_json }}",
);
expect(packStep?.run).toContain("plugin-npm-security-prepare.mts prepare");
expect(packStep?.run).toContain("if ! node --import tsx");
expect(packStep?.run).toContain("Package preparation failed");
expect(
securityScan.steps.find(
(step: WorkflowStep) => step.name === "Scan supplemental inert plugin inputs",
)?.run,
).toContain("node scripts/plugin-npm-security-scan-runner.mjs");
expect(
securityScan.steps.find(
(step: WorkflowStep) => step.name === "Scan supplemental inert plugin inputs",
)?.run,
).toContain('--target-context-ref "$TARGET_CONTEXT_REF"');
expect(source).not.toContain("actions/download-artifact@");
expect(source).not.toContain("plugin-npm-security-artifact-plan");
expect(source).not.toContain("npm-install-security-scan.release.test.ts");
expect(workflow.on.workflow_dispatch.inputs.target_context_ref).toEqual({
default: "",
description: "Canonical release context for an exact-SHA frozen-target validation",
required: false,
type: "string",
});
});
it("binds scanner identity independently of candidate-owned outputs", () => {
const workflow = readPluginPrereleaseWorkflow();
const root = tempDirs.make("openclaw-plugin-prerelease-identity-");
const admittedSha = "a".repeat(40);
const substitutedSha = "b".repeat(40);
mkdirSync(join(root, "scripts/lib"), { recursive: true });
mkdirSync(join(root, "workflow/scripts/github"), { recursive: true });
mkdirSync(join(root, "bin"));
symlinkSync(resolve("node_modules"), join(root, "node_modules"), "dir");
writeFileSync(
join(root, "workflow/scripts/github/resolve-openclaw-ref.sh"),
readFileSync("scripts/github/resolve-openclaw-ref.sh"),
);
writeFileSync(
join(root, "bin/git"),
`#!/bin/sh\n[ "$1" = rev-parse ] && [ "$2" = HEAD ] || exit 2\nprintf '%s\\n' '${admittedSha}'\n`,
{ mode: 0o755 },
);
writeFileSync(
join(root, "scripts/lib/plugin-prerelease-test-plan.mts"),
`import { appendFileSync } from "node:fs";
process.once("exit", () => appendFileSync(process.env.GITHUB_OUTPUT,
"checkout_revision=${substitutedSha}\\n"));
export function assertPluginPrereleaseTestPlanComplete() {
return { staticChecks: [{ checkName: "fixture", command: "true", check: "fixture" }], dockerLanes: [] };
}`,
);
writeFileSync(
join(root, "scripts/lib/extension-test-plan.mts"),
"export const DEFAULT_EXTENSION_TEST_SHARD_COUNT = 1; export function createExtensionTestShards() { return []; }",
);
writeFileSync(
join(root, "scripts/lib/ci-node-test-plan.mts"),
"export function createNodeTestShards() { return []; }",
);
const needs: Record<string, { outputs: Record<string, string> }> = {};
for (const [jobId, stepName] of [
["resolve_target", "Resolve target SHA"],
["preflight", "Build plugin prerelease manifest"],
] as const) {
const job = workflow.jobs[jobId];
if (!job) {
continue;
}
const step = job.steps.find((entry: WorkflowStep) => entry.name === stepName);
const outputPath = join(root, `${jobId}-output`);
const result = spawnSync("bash", ["-c", step.run], {
cwd: root,
encoding: "utf8",
env: {
EXPECTED_SHA: admittedSha,
FULL_RELEASE_VALIDATION: "true",
GITHUB_OUTPUT: outputPath,
PATH: `${join(root, "bin")}:${process.env.PATH}`,
PHASE: "independent",
TARGET_REF: admittedSha,
},
});
expect(result.status, result.stderr).toBe(0);
// Actions assigns each output in file order; the last duplicate wins.
const outputs = Object.fromEntries(
readFileSync(outputPath, "utf8")
.trim()
.split("\n")
.map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]),
);
if (jobId === "preflight") {
expect(outputs.checkout_revision).toBe(substitutedSha);
}
needs[jobId] = {
outputs: Object.fromEntries(
Object.entries(job.outputs).map(([key, expression]) => [
key,
runInNewContext(String(expression).slice(3, -2), {
steps: { [step.id]: { outputs }, node_test_exclusions: { outputs: {} } },
}),
]),
),
};
}
for (const jobId of ["plugin-npm-security-plan", "plugin-npm-security-scan"]) {
for (const step of workflow.jobs[jobId].steps as WorkflowStep[]) {
const candidateRef = step.with?.path === ".release-candidate" ? step.with.ref : undefined;
for (const expression of [candidateRef, step.env?.CANDIDATE_SHA]) {
if (expression !== undefined) {
if (typeof expression !== "string") {
throw new Error("Candidate identity must be a workflow expression");
}
expect(runInNewContext(expression.slice(3, -2), { needs })).toBe(admittedSha);
}
}
}
}
});
it("wires the full plugin prerelease plan into its release workflow", () => {
const workflow = readCiWorkflow();
const preflight = workflow.jobs.preflight;
const pluginWorkflow = readPluginPrereleaseWorkflow();
const pluginPreflight = pluginWorkflow.jobs.preflight;
const securityScan = pluginWorkflow.jobs["plugin-npm-security-scan"];
const staticShard = pluginWorkflow.jobs["plugin-prerelease-static-shard"];
const nodeShard = pluginWorkflow.jobs["plugin-prerelease-node-shard"];
const extensionShard = pluginWorkflow.jobs["plugin-prerelease-extension-shard"];
const inspector = pluginWorkflow.jobs["plugin-prerelease-inspector"];
const dockerSuite = pluginWorkflow.jobs["plugin-prerelease-docker-suite"];
const suite = pluginWorkflow.jobs["plugin-prerelease-suite"];
const releaseWorkflow = readFullReleaseValidationWorkflow();
const releaseWorkflowSource = readFileSync(
".github/workflows/full-release-validation.yml",
"utf8",
);
const manifestScript = readFileSync("scripts/ci-build-manifest.mjs", "utf8");
const manifestEnv = preflight.steps.find(
(step: WorkflowStep) => step.name === "Build CI manifest",
).env;
const pluginManifestScript = pluginPreflight.steps.find(
(step: WorkflowStep) => step.name === "Build plugin prerelease manifest",
).run;
const pluginManifestEnv = pluginPreflight.steps.find(
(step: WorkflowStep) => step.name === "Build plugin prerelease manifest",
).env;
const normalCiScript = releaseWorkflow.jobs.normal_ci.steps.find(
(step: WorkflowStep) => step.name === "Dispatch CI",
).run;
const pluginPrereleaseScript = releaseWorkflow.jobs.plugin_prerelease_candidate.steps.find(
(step: WorkflowStep) => step.name === "Dispatch plugin prerelease candidate phase",
).run;
const releaseChecksStep = releaseWorkflow.jobs.release_checks_candidate.steps.find(
(step: WorkflowStep) => step.name === "Dispatch release checks candidate phase",
);
const releaseChecksScript = releaseChecksStep.run;
const buildDistStep = workflow.jobs["build-artifacts"].steps.find(
(step: WorkflowStep) => step.name === "Build dist",
);
expect(workflow.jobs["plugin-prerelease-static-shard"]).toBeUndefined();
expect(workflow.jobs["plugin-prerelease-inspector"]).toBeUndefined();
expect(workflow.jobs["plugin-prerelease-docker-suite"]).toBeUndefined();
expect(workflow.jobs["plugin-prerelease-suite"]).toBeUndefined();
expect(workflow.jobs["checks-node-extensions-shard"]).toBeUndefined();
expect(preflight.outputs).not.toHaveProperty("run_plugin_prerelease_suite");
expect(preflight.outputs).not.toHaveProperty("run_checks_node_extensions");
expect(buildDistStep.env).toEqual({ NODE_OPTIONS: "--max-old-space-size=8192" });
expect(evaluateWorkflowRunner(staticShard["runs-on"])).toBe("ubuntu-24.04");
expect(evaluateWorkflowRunner(staticShard["runs-on"], { eventName: "push" })).toBe(
"blacksmith-8vcpu-ubuntu-2404",
);
expect(staticShard).toEqual({
if: "needs.preflight.outputs.run_plugin_prerelease_static == 'true'",
name: "${{ matrix.check_name || 'plugin-prerelease-static-shard' }}",
needs: ["resolve_target", "preflight"],
permissions: {
contents: "read",
},
"runs-on": expect.any(String),
steps: [
{
name: "Checkout",
uses: CHECKOUT_V6,
with: {
"fetch-depth": 1,
"fetch-tags": false,
"persist-credentials": false,
ref: "${{ needs.resolve_target.outputs.checkout_revision }}",
submodules: false,
},
},
{
name: "Setup Node environment",
uses: "./.github/actions/setup-node-env",
with: {
"cache-mode": "restore",
"install-bun": "false",
},
},
{
env: {
PLUGIN_PRERELEASE_COMMAND: "${{ matrix.command }}",
PLUGIN_PRERELEASE_TASK: "${{ matrix.task }}",
},
name: "Run plugin prerelease static shard",
run: [
"set -euo pipefail",
'echo "Running ${PLUGIN_PRERELEASE_TASK}: ${PLUGIN_PRERELEASE_COMMAND}"',
'bash -c "$PLUGIN_PRERELEASE_COMMAND"',
"",
].join("\n"),
shell: "bash",
},
],
strategy: {
"fail-fast": false,
matrix: "${{ fromJson(needs.preflight.outputs.plugin_prerelease_static_matrix) }}",
},
"timeout-minutes": 45,
});
expect(workflow.on.workflow_dispatch.inputs.full_release_validation).toBeUndefined();
expect(workflow.on.workflow_dispatch.inputs.include_android).toEqual({
default: false,
description: "Run Android lanes for this manual CI dispatch.",
required: false,
type: "boolean",
});
expect(workflow.on.workflow_dispatch.inputs.historical_target_tag).toEqual({
default: "",
description: "Semver release tag authorizing compatibility fallbacks for its exact commit",
required: false,
type: "string",
});
expect(workflow.on.workflow_dispatch.inputs.release_candidate_ref).toEqual({
default: "",
description:
"Canonical release branch authorizing compatibility fallbacks for its exact head",
required: false,
type: "string",
});
expect(workflow.on.workflow_dispatch.inputs.target_context_ref).toEqual({
default: "",
description:
"Canonical release branch context authorizing compatibility fallbacks for an exact-SHA target",
required: false,
type: "string",
});
expect(manifestEnv).toMatchObject({
OPENCLAW_CI_CHANGED_PATHS_JSON:
"${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}",
OPENCLAW_CI_CHECKOUT_REVISION: "${{ steps.checkout_ref.outputs.sha }}",
OPENCLAW_CI_DOCS_CHANGED:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.docs_scope.outputs.docs_changed }}",
OPENCLAW_CI_DOCS_ONLY:
"${{ github.event_name == 'schedule' && 'false' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.docs_scope.outputs.docs_only }}",
OPENCLAW_CI_EVENT_NAME: "${{ github.event_name }}",
OPENCLAW_CI_HISTORICAL_TARGET: "${{ steps.historical_target.outputs.eligible || 'false' }}",
OPENCLAW_CI_RELEASE_GATE: "${{ inputs.release_gate && 'true' || 'false' }}",
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET:
"${{ steps.release_candidate_target.outputs.eligible || 'false' }}",
OPENCLAW_CI_TARGET_CONTEXT_TARGET:
"${{ steps.target_context_target.outputs.eligible || 'false' }}",
OPENCLAW_CI_REPOSITORY: "${{ github.repository }}",
OPENCLAW_CI_RUNNER_PROFILE: "${{ steps.runner_profile.outputs.runner_profile }}",
OPENCLAW_CI_NODE_RUNNER_BACKEND: "${{ steps.runner_profile.outputs.node_runner_backend }}",
OPENCLAW_CI_QUALIFICATION: "${{ steps.runner_profile.outputs.ci_qualification }}",
OPENCLAW_CI_SHAPE: "${{ steps.runner_profile.outputs.ci_shape }}",
OPENCLAW_CI_RUN_ANDROID:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && ((inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true')) || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}",
OPENCLAW_CI_RUN_CONTROL_UI_I18N:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}",
OPENCLAW_CI_RUN_IOS_BUILD:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}",
OPENCLAW_CI_RUN_MACOS:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}",
OPENCLAW_CI_RUN_MACOS_NODE:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos_node || 'false' }}",
OPENCLAW_CI_RUN_NATIVE_I18N:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}",
OPENCLAW_CI_RUN_NODE:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_node || 'false' }}",
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING:
"${{ github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}",
OPENCLAW_CI_RUN_NODE_FAST_ONLY:
"${{ github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}",
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS:
"${{ github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}",
OPENCLAW_CI_RUN_SKILLS_PYTHON:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}",
OPENCLAW_CI_RUN_UI_TESTS:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}",
OPENCLAW_CI_RUN_WINDOWS:
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}",
OPENCLAW_CI_WORKFLOW_REVISION: "${{ github.workflow_sha }}",
});
expect(manifestEnv).not.toHaveProperty("OPENCLAW_CI_FULL_RELEASE_VALIDATION");
expect(manifestScript).toContain("includeReleaseOnlyPluginShards: false");
expect(manifestScript).not.toContain("plugin-prerelease-test-plan.mts");
expect(
workflow.jobs["check-shard"].steps.find(
(step: WorkflowStep) => step.name === "Run check shard",
).run,
).toContain("pnpm deadcode:ci");
expect(releaseChecksStep.env?.TARGET_CONTEXT_REF).toBe("${{ inputs.target_context_ref }}");
expect(releaseChecksScript).toContain('-f ref="$TARGET_SHA"');
expect(releaseChecksScript).toContain('-f target_context_ref="$TARGET_CONTEXT_REF"');
expect(releaseChecksScript).toContain("args+=(-f allow_frozen_target_scenario_omissions=true)");
expect(releaseWorkflowSource).toContain('--arg targetContextRef "$TARGET_CONTEXT_REF"');
expect(releaseWorkflowSource).toContain("targetContextRef: $targetContextRef");
expect(normalCiScript).toContain('dispatch_child ci.yml "$dispatch_run_name" "${args[@]}"');
const normalCiDispatchCase = normalCiScript.match(/^\s*ci\)\n([\s\S]*?)^\s*;;$/mu)?.[1];
expect(normalCiDispatchCase).toContain('dispatch_child ci.yml "$dispatch_run_name"');
expect(normalCiDispatchCase).not.toContain("full_release_validation=true");
expect(pluginPrereleaseScript).toContain('-f phase="$PHASE"');
expect(pluginPrereleaseScript).toContain(
'args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON")',
);
expect(pluginPrereleaseScript).toContain(
'dispatch_child plugin-prerelease.yml "$dispatch_run_name" "${args[@]}"',
);
expect(pluginManifestScript).toContain("await import(");
expect(pluginManifestScript).toContain('"./scripts/lib/plugin-prerelease-test-plan.mts"');
expect(pluginManifestScript).toContain('"./scripts/lib/extension-test-plan.mts"');
expect(pluginManifestScript).toContain('"./scripts/lib/ci-node-test-plan.mts"');
expect(pluginManifestScript).toContain("const { createNodeTestShards } = await import");
expect(pluginManifestScript).not.toContain("createNodeTestShardBundles");
expect(pluginManifestScript).not.toContain("compactMode");
expect(pluginManifestScript).not.toContain("runnerBackend");
expect(pluginManifestScript).toContain('shard.shardName === "agentic-plugins"');
expect(pluginManifestScript).toContain(
"Plugin prerelease plan unavailable in target ref; skipping static and Docker plugin prerelease lanes.",
);
const pluginNodeShardScript = pluginWorkflow.jobs["plugin-prerelease-node-shard"].steps.find(
(step: WorkflowStep) => step.name === "Run release-only plugin Node shard",
).run;
expect(pluginNodeShardScript).toContain(
'".frv-tooling/scripts/frv-test-exclusions.mjs", "run", "--", "pnpm", "test", "--", ...configs',
);
expect(pluginNodeShardScript).not.toContain("scripts/test-projects.mts");
expect(pluginWorkflow.on.workflow_dispatch.inputs.target_ref).toEqual({
default: "main",
description: "Branch, tag, or full commit SHA to validate",
required: false,
type: "string",
});
expect(pluginWorkflow.on.workflow_dispatch.inputs.full_release_validation).toEqual({
default: false,
description: "Enable release-only Docker prerelease lanes from Full Release Validation",
required: false,
type: "boolean",
});
expect(pluginWorkflow.on.workflow_dispatch.inputs.phase).toEqual({
default: "all",
description: "Plugin prerelease phase to run",
options: ["all", "independent", "candidate"],
required: false,
type: "choice",
});
expect(pluginWorkflow.on.workflow_dispatch.inputs.dispatch_id).toEqual({
description: "Optional parent workflow dispatch identifier",
required: false,
default: "",
type: "string",
});
expect(pluginManifestEnv).toEqual({
NODE_TEST_EXCLUDE_PATTERNS_JSON: "${{ steps.node_test_exclusions.outputs.patterns_json }}",
EXTENSION_TEST_EXCLUDE_PATTERNS_JSON:
"${{ steps.node_test_exclusions.outputs.extension_patterns_json }}",
FULL_RELEASE_VALIDATION: "${{ inputs.full_release_validation && 'true' || 'false' }}",
PHASE: "${{ github.event_name == 'schedule' && 'independent' || inputs.phase }}",
});
expect(pluginManifestScript).toContain(
'const fullReleaseValidation = process.env.FULL_RELEASE_VALIDATION === "true";',
);
expect(pluginManifestScript).toContain('const runIndependent = phase !== "candidate";');
expect(pluginManifestScript).toContain('const runCandidate = phase !== "independent";');
expect(pluginManifestScript).toContain(
"const runDocker = runCandidate && fullReleaseValidation && dockerLanes.length > 0;",
);
expect(pluginPreflight.outputs).toEqual({
plugin_prerelease_docker_lanes:
"${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }}",
plugin_prerelease_extension_matrix:
"${{ steps.manifest.outputs.plugin_prerelease_extension_matrix }}",
plugin_prerelease_node_matrix: "${{ steps.manifest.outputs.plugin_prerelease_node_matrix }}",
node_test_exclude_patterns_json: "${{ steps.node_test_exclusions.outputs.patterns_json }}",
extension_test_exclude_patterns_json:
"${{ steps.node_test_exclusions.outputs.extension_patterns_json }}",
plugin_prerelease_static_matrix:
"${{ steps.manifest.outputs.plugin_prerelease_static_matrix }}",
run_plugin_prerelease_docker: "${{ steps.manifest.outputs.run_plugin_prerelease_docker }}",
run_plugin_prerelease_extensions:
"${{ steps.manifest.outputs.run_plugin_prerelease_extensions }}",
run_plugin_prerelease_inspector:
"${{ steps.manifest.outputs.run_plugin_prerelease_inspector }}",
run_plugin_prerelease_node: "${{ steps.manifest.outputs.run_plugin_prerelease_node }}",
run_plugin_prerelease_static: "${{ steps.manifest.outputs.run_plugin_prerelease_static }}",
run_plugin_prerelease_suite: "${{ steps.manifest.outputs.run_plugin_prerelease_suite }}",
});
expect(staticShard.strategy.matrix).toBe(
"${{ fromJson(needs.preflight.outputs.plugin_prerelease_static_matrix) }}",
);
expect(securityScan.needs).toEqual(["resolve_target", "plugin-npm-security-plan"]);
expect(nodeShard.strategy.matrix).toBe(
"${{ fromJson(needs.preflight.outputs.plugin_prerelease_node_matrix) }}",
);
expect(extensionShard.if).toBe(
"needs.preflight.outputs.run_plugin_prerelease_extensions == 'true'",
);
expect(extensionShard.strategy.matrix).toBe(
"${{ fromJson(needs.preflight.outputs.plugin_prerelease_extension_matrix) }}",
);
expect(
extensionShard.steps.find(
(step: WorkflowStep) => step.name === "Setup pinned Bun test runtime",
),
).toMatchObject({
if: "matrix.requires_bun == true",
uses: "./.github/actions/setup-test-bun",
});
expect(
extensionShard.steps.find((step: WorkflowStep) => step.name === "Run extension shard").env
.OPENCLAW_CI_TEST_RUNTIME_POLICY,
).toBe("${{ matrix.test_runtime_policy || 'node' }}");
expect(inspector.name).toBe("plugin-prerelease-inspector");
expect(inspector.needs).toEqual(["resolve_target", "preflight"]);
expect(inspector.if).toBe("needs.preflight.outputs.run_plugin_prerelease_inspector == 'true'");
expect(inspector["continue-on-error"]).toBe(true);
expect(evaluateWorkflowRunner(inspector["runs-on"])).toBe("ubuntu-24.04");
expect(inspector["timeout-minutes"]).toBe(30);
expect(
inspector.steps.find((step: WorkflowStep) => step.name === "Setup Node environment").with,
).toEqual({
"cache-mode": "restore",
"install-bun": "false",
});
const inspectorRun = inspector.steps.find(
(step: WorkflowStep) => step.name === "Run plugin inspector advisory sweep",
);
expect(inspectorRun.env).toEqual({
OPENCLAW_PLUGIN_INSPECTOR_ROOT: ".artifacts/plugin-inspector",
OPENCLAW_PLUGIN_INSPECTOR_VERSION: "0.3.26",
});
expect(inspectorRun.run).toContain("extensions/");
expect(inspectorRun.run).toContain(
'npm exec --yes "@openclaw/plugin-inspector@${OPENCLAW_PLUGIN_INSPECTOR_VERSION}" -- ci',
);
expect(inspectorRun.run).toContain("This job is informational");
expect(
inspector.steps.find(
(step: WorkflowStep) => step.name === "Upload plugin inspector advisory artifacts",
),
).toEqual({
if: "always()",
name: "Upload plugin inspector advisory artifacts",
uses: UPLOAD_ARTIFACT_V7,
with: {
"if-no-files-found": "warn",
name: "plugin-inspector-advisory",
path: ".artifacts/plugin-inspector/**",
},
});
expect(
staticShard.steps.find(
(step: WorkflowStep) => step.name === "Run plugin prerelease static shard",
).run,
).toContain('bash -c "$PLUGIN_PRERELEASE_COMMAND"');
expect(dockerSuite).toMatchObject({
if: "${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }}",
name: "plugin-prerelease-docker-suite",
needs: ["resolve_target", "preflight"],
permissions: {
actions: "read",
contents: "read",
packages: "read",
"pull-requests": "read",
},
uses: "./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml",
with: {
docker_lanes: "${{ needs.preflight.outputs.plugin_prerelease_docker_lanes }}",
include_live_suites: false,
include_openwebui: false,
include_release_path_suites: false,
include_repo_e2e: false,
live_models_only: false,
allow_unreleased_changelog: true,
ref: "${{ needs.resolve_target.outputs.checkout_revision }}",
shared_image_artifact_namespace: "plugin-prerelease",
shared_image_policy: "no-push-artifact",
targeted_docker_lane_group_size: 2,
},
});
expect(dockerSuite.with.enable_prepublish_plugin_registry).toBe(true);
expect(
Object.keys(dockerSuite.with).filter((key) => key.startsWith("prepublish_plugin_registry_")),
).toEqual([
"prepublish_plugin_registry_artifact_name",
"prepublish_plugin_registry_artifact_id",
"prepublish_plugin_registry_artifact_digest",
"prepublish_plugin_registry_artifact_run_id",
"prepublish_plugin_registry_artifact_run_attempt",
"prepublish_plugin_registry_manifest_sha256",
]);
expect(dockerSuite.with.package_artifact_id).toBe(
"${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactId || '' }}",
);
expect(dockerSuite.with.shared_image_artifact_id).toBe(
"${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactId || '' }}",
);
expect(dockerSuite.secrets).toBeUndefined();
expect(suite.needs).toEqual([
"preflight",
"plugin-npm-security-scan",
"plugin-prerelease-static-shard",
"plugin-prerelease-node-shard",
"plugin-prerelease-extension-shard",
"plugin-prerelease-inspector",
"plugin-prerelease-docker-suite",
]);
expect(
suite.steps.find((step: WorkflowStep) => step.name === "Verify plugin prerelease suite").run,
).toContain("plugin-prerelease-inspector advisory result");
expect(
suite.steps.find((step: WorkflowStep) => step.name === "Verify plugin prerelease suite").run,
).toContain('check_required "plugin-npm-security-scan" "$RUN_NPM_SECURITY" "$SECURITY_RESULT"');
});
it.each([
{
expected: {
docker: "true",
extensions: "true",
inspector: "true",
node: "true",
static: "true",
},
phase: "all",
},
{
expected: {
docker: "false",
extensions: "true",
inspector: "true",
node: "true",
static: "true",
},
phase: "independent",
},
{
expected: {
docker: "true",
extensions: "false",
inspector: "false",
node: "false",
static: "false",
},
phase: "candidate",
},
] as const)("routes only the $phase plugin prerelease phase", ({ expected, phase }) => {
const { output, result } = runPluginManifest(phase);
expect(result.status, result.stderr).toBe(0);
for (const [lane, scheduled] of Object.entries(expected)) {
expect(output).toContain(`run_plugin_prerelease_${lane}=${scheduled}\n`);
}
if (phase === "independent") {
const matrixLine = expectDefined(
output.split("\n").find((line) => line.startsWith("plugin_prerelease_extension_matrix=")),
"extension matrix output",
);
const matrix = JSON.parse(matrixLine.slice(matrixLine.indexOf("=") + 1)) as {
include: {
extensions_csv: string;
requires_bun?: boolean;
task: string;
test_runtime_policy?: string;
}[];
};
const bunRows = matrix.include.filter((row) => row.requires_bun);
expect(
bunRows
.flatMap((row) => row.extensions_csv.split(",").filter((id) => id.startsWith("memory-")))
.toSorted(),
).toEqual(["memory-lancedb", "memory-wiki"]);
expect(
bunRows.every(
(row) => row.task === "extensions-batch" && row.test_runtime_policy === "dual",
),
).toBe(true);
expect(
matrix.include
.filter((row) => row.task === "extensions-batch" && !row.requires_bun)
.every((row) => row.test_runtime_policy === "node"),
).toBe(true);
}
});
it("requires a complete immutable candidate for the plugin candidate phase", () => {
const selectedSha = "a".repeat(40);
const independent = runPluginPhaseValidation({
candidateArtifactJson: "",
phase: "independent",
});
const missing = runPluginPhaseValidation({
candidateArtifactJson: "",
expectedSha: selectedSha,
phase: "candidate",
});
const valid = runPluginPhaseValidation({
candidateArtifactJson: pluginCandidateArtifactJson(selectedSha),
expectedSha: selectedSha,
phase: "candidate",
});
expect(independent.status, independent.stderr).toBe(0);
expect(missing.status).toBe(1);
expect(missing.stderr).toContain(
"phase=candidate requires the complete immutable package and Docker image artifact tuple.",
);
expect(valid.status, valid.stderr).toBe(0);
});
it("keeps hourly extension coverage with its complete release owner and required result", () => {
const workflow = readPluginPrereleaseWorkflow();
const context = {
eventName: "schedule" as const,
repository: "openclaw/openclaw",
ref: "refs/heads/main",
sha: "a".repeat(40),
runAttempt: 1,
};
const evaluate = (value: string, overrides = {}) =>
evaluateWorkflowExpression(value.startsWith("${{") ? value : `\${{ ${value} }}`, {
...context,
...overrides,
});
expect(workflow.on.schedule).toEqual([{ cron: "37 * * * *" }]);
expect(workflow.permissions).toEqual({ contents: "read" });
expect(evaluate(workflow.jobs.resolve_target.if)).toBe(true);
expect(evaluate(workflow.jobs.resolve_target.if, { repository: "contributor/openclaw" })).toBe(
false,
);
expect(evaluate(workflow.jobs.resolve_target.if, { ref: "refs/heads/topic" })).toBe(false);
expect(
evaluate(workflow.jobs["plugin-prerelease-suite"].if, {
repository: "contributor/openclaw",
preflightOutputs: {},
}),
).toBe(false);
const resolver = workflow.jobs.resolve_target.steps.find(
(step: WorkflowStep) => step.id === "resolve",
);
expect(evaluate(resolver.env.TARGET_REF)).toBe(context.sha);
expect(evaluate(resolver.env.EXPECTED_SHA)).toBe(context.sha);
expect(evaluate(workflow.concurrency.group)).toBe("plugin-prerelease-hourly-main");
expect(evaluate(workflow.concurrency.group, { sha: "b".repeat(40) })).toBe(
"plugin-prerelease-hourly-main",
);
expect(evaluate(workflow.concurrency["cancel-in-progress"])).toBe(false);
expect(evaluate(workflow.jobs["plugin-npm-security-plan"].if)).toBe(false);
const validation = workflow.jobs.preflight.steps.find(
(step: WorkflowStep) => step.name === "Validate phase inputs",
);
expect(evaluate(validation.env.PHASE)).toBe("independent");
const exclusions = workflow.jobs.preflight.steps.find(
(step: WorkflowStep) => step.id === "node_test_exclusions",
);
expect(evaluate(exclusions.env.NODE_TEST_EXCLUDE_PATTERNS_JSON)).toBe("[]");
expect(evaluate(exclusions.env.EXTENSION_TEST_EXCLUDE_PATTERNS_JSON)).toBe("[]");
const summaryStep = workflow.jobs["plugin-prerelease-suite"].steps.find(
(step: WorkflowStep) => step.name === "Verify plugin prerelease suite",
);
expect(evaluate(summaryStep.env.RUN_NPM_SECURITY)).toBe("false");
expect(workflow.jobs["plugin-prerelease-extension-shard"].strategy["max-parallel"]).toBe(12);
const hourly = runPluginManifest("independent", "schedule");
const release = runPluginManifest("independent");
expect(hourly.result.status, hourly.result.stderr).toBe(0);
expect(release.result.status, release.result.stderr).toBe(0);
const outputs = (output: string) =>
Object.fromEntries(
output
.trim()
.split("\n")
.map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]),
);
const selected = outputs(hourly.output);
const ownership = (matrix: string | undefined) =>
JSON.parse(expectDefined(matrix, "extension matrix")).include.map(
({ requires_bun: _bun, test_runtime_policy: _runtime, ...row }: Record<string, unknown>) =>
row,
);
expect(ownership(selected.plugin_prerelease_extension_matrix)).toEqual(
ownership(outputs(release.output).plugin_prerelease_extension_matrix),
);
const rows: {
task: string;
extensions_csv: string;
includePatterns?: string[];
exclusion_configs: { config: string; includePatterns: string[] }[];
requires_bun?: boolean;
test_runtime_policy?: string;
}[] = JSON.parse(
expectDefined(selected.plugin_prerelease_extension_matrix, "hourly extension matrix"),
).include;
expect(
rows.every((row) => !row.requires_bun && (row.test_runtime_policy ?? "node") === "node"),
).toBe(true);
const extensionIds = new Set(rows.flatMap((row) => row.extensions_csv.split(",")));
for (const id of ["device-pair", "active-memory", "talk-voice"]) {
expect(extensionIds, id).toContain(id);
}
expect(listAvailableExtensionIds()).toContain("image-generation-core");
expect(resolveExtensionTestPlan({ targetArg: "image-generation-core" })).toMatchObject({
hasTests: false,
testFileCount: 0,
});
expect(extensionIds).not.toContain("image-generation-core");
const fileTargets = rows
.filter((row) => row.task === "extension-file-shard")
.flatMap((row) => row.includePatterns ?? []);
expect(new Set(fileTargets).size).toBe(fileTargets.length);
const sourceOnlyFile = "extensions/device-pair/doctor-contract-api.test.ts";
expect(fileTargets).not.toContain(sourceOnlyFile);
for (const file of [sourceOnlyFile, "extensions/plugin-entry.cli-laziness.test.ts"]) {
const config = resolveExtensionTestConfig(file);
expect(
rows.filter((row) =>
row.exclusion_configs.some(
(group) =>
group.config === config &&
group.includePatterns.some((pattern) => matchesGlob(file, pattern)),
),
),
file,
).toHaveLength(1);
}
expect(
rows.filter((row) =>
row.includePatterns?.includes("extensions/plugin-entry.cli-laziness.test.ts"),
),
).toEqual([
expect.objectContaining({
task: "extension-file-shard",
vitest_config: "test/vitest/vitest.extensions.config.ts",
}),
]);
expect(selected.run_plugin_prerelease_extensions).toBe("true");
expect(selected.run_plugin_prerelease_suite).toBe("true");
for (const family of ["static", "node", "inspector", "docker"]) {
expect(selected[`run_plugin_prerelease_${family}`]).toBe("false");
}
for (const result of ["success", "failure", "cancelled", "skipped"]) {
const summary = runPluginSummary({
docker: "skipped",
extensions: result,
node: "skipped",
static: "skipped",
runDocker: false,
runExtensions: true,
runNode: false,
runNpmSecurity: false,
runStatic: false,
});
expect(summary.status).toBe(result === "success" ? 0 : 1);
}
});
it("validates only scheduled plugin jobs in each phase summary", () => {
const independent = runPluginSummary({
docker: "failure",
extensions: "success",
node: "success",
runDocker: false,
runExtensions: true,
runNode: true,
runNpmSecurity: true,
runStatic: true,
static: "success",
});
const candidate = runPluginSummary({
docker: "success",
extensions: "failure",
node: "failure",
runDocker: true,
runExtensions: false,
runNode: false,
runNpmSecurity: false,
runStatic: false,
static: "failure",
});
const failedCandidate = runPluginSummary({
docker: "failure",
extensions: "skipped",
node: "skipped",
runDocker: true,
runExtensions: false,
runNode: false,
runNpmSecurity: false,
runStatic: false,
static: "skipped",
});
expect(independent.status, independent.stderr).toBe(0);
expect(candidate.status, candidate.stderr).toBe(0);
expect(failedCandidate.status).toBe(1);
expect(`${failedCandidate.stdout}\n${failedCandidate.stderr}`).toContain(
"plugin-prerelease-docker ended with failure",
);
});
it("keeps exact release tuples independent without cancelling adopted children", () => {
const releaseChecksWorkflow = parse(
readFileSync(".github/workflows/openclaw-release-checks.yml", "utf8"),
);
const fullReleaseWorkflow = readFullReleaseValidationWorkflow();
expect(releaseChecksWorkflow.concurrency).toEqual({
group:
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}-${{ inputs.phase }}-${{ inputs.release_profile == 'minimum' && 'beta' || inputs.release_profile }}-${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }}",
"cancel-in-progress": false,
});
expect(readPluginPrereleaseWorkflow().concurrency).toEqual({
group:
"${{ github.event_name == 'schedule' && 'plugin-prerelease-hourly-main' || format('plugin-prerelease-{0}-{1}-{2}', inputs.target_ref, github.sha, inputs.phase) }}",
"cancel-in-progress": "${{ github.event_name != 'schedule' && inputs.target_ref == 'main' }}",
});
expect(fullReleaseWorkflow.concurrency).toEqual({
group:
"full-release-validation-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}-${{ inputs.release_profile == 'minimum' && 'beta' || inputs.release_profile }}-${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }}",
"cancel-in-progress": false,
});
for (const workflow of [fullReleaseWorkflow, releaseChecksWorkflow]) {
const coverageKey = (profile: string, soak: boolean) =>
workflow.concurrency.group.replace(
/\$\{\{\s*([\s\S]*?)\s*\}\}/gu,
(_: string, expression: string) =>
String(
runInNewContext(expression, {
github: { sha: "a".repeat(40) },
inputs: {
expected_sha: "b".repeat(40),
rerun_group: "all",
phase: "candidate",
release_profile: profile,
run_release_soak: soak,
},
}),
),
);
expect(
new Set([
coverageKey("beta", false),
coverageKey("beta", true),
coverageKey("stable", false),
coverageKey("full", false),
]).size,
).toBe(4);
expect(coverageKey("minimum", false)).toBe(coverageKey("beta", false));
for (const profile of ["stable", "full"]) {
expect(coverageKey(profile, false)).toBe(coverageKey(profile, true));
}
}
expect(fullReleaseWorkflow.on.workflow_dispatch.inputs.expected_sha).toEqual({
description: "Optional full Validation SHA that ref must resolve to",
required: false,
default: "",
type: "string",
});
const resolveTargetStep = fullReleaseWorkflow.jobs.resolve_target.steps.find(
(step: WorkflowStep) => step.name === "Resolve target SHA",
);
const targetSummaryStep = fullReleaseWorkflow.jobs.resolve_target.steps.find(
(step: WorkflowStep) => step.name === "Summarize target",
);
expect(resolveTargetStep.env?.EXPECTED_SHA).toBe("${{ inputs.expected_sha }}");
expect(resolveTargetStep.run).toContain('--expected-sha "$EXPECTED_SHA"');
expect(targetSummaryStep.run).toContain("- Validation SHA:");
expect(targetSummaryStep.run).not.toContain("- Code SHA:");
expect(evaluateWorkflowRunner(releaseChecksWorkflow.jobs.resolve_target["runs-on"])).toBe(
"ubuntu-24.04",
);
expect(
evaluateWorkflowRunner(releaseChecksWorkflow.jobs.prepare_release_package["runs-on"]),
).toBe("ubuntu-24.04");
expect(evaluateWorkflowRunner(releaseChecksWorkflow.jobs.summary["runs-on"])).toBe(
"ubuntu-24.04",
);
for (const jobName of [
"normal_ci",
"plugin_prerelease_independent",
"plugin_prerelease_candidate",
"npm_telegram",
"summary",
]) {
expect(evaluateWorkflowRunner(fullReleaseWorkflow.jobs[jobName]["runs-on"])).toBe(
"ubuntu-24.04",
);
}
expect(fullReleaseWorkflow.jobs.normal_ci["timeout-minutes"]).toBe(15);
expect(fullReleaseWorkflow.jobs.normal_ci.needs).toEqual([
"resolve_target",
"plugin_compatibility_readiness",
"evidence_reuse",
]);
expect(fullReleaseWorkflow.jobs.normal_ci.if).toContain(
"needs.resolve_target.result == 'success'",
);
expect(fullReleaseWorkflow.jobs.normal_ci.if).toContain(
"needs.evidence_reuse.outputs.reuse != 'true'",
);
for (const jobName of [
"plugin_prerelease_independent",
"plugin_prerelease_candidate",
"release_checks_independent",
"release_checks_candidate",
"npm_telegram",
"performance",
]) {
expect(fullReleaseWorkflow.jobs[jobName]["timeout-minutes"], jobName).toBe(15);
}
const fullReleaseSource = readFileSync(".github/workflows/full-release-validation.yml", "utf8");
expect(fullReleaseWorkflow.on.workflow_dispatch.inputs.fail_fast).toEqual({
description:
"Cancel only an exact active child after its first blocking job; false drains all children and permits same-parent recovery",
required: false,
default: false,
type: "boolean",
});
for (const [jobName, kind] of [
["normal_ci", "ci"],
["plugin_prerelease_independent", "plugin-prerelease"],
["plugin_prerelease_candidate", "plugin-prerelease"],
["release_checks_independent", "release-checks"],
["release_checks_candidate", "release-checks"],
["npm_telegram", "npm-telegram"],
] as const) {
const dispatch = expectDefined(
fullReleaseWorkflow.jobs[jobName].steps.find(
(step: WorkflowStep) => step.id === "dispatch",
) as WorkflowStep | undefined,
`${jobName} dispatch step`,
);
expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(kind);
if (jobName.startsWith("release_checks_")) {
expect(dispatch.env?.FAIL_FAST).toBe("${{ inputs.fail_fast }}");
expect(dispatch.run).toContain('-f fail_fast="$FAIL_FAST"');
} else {
expect(dispatch.env).not.toHaveProperty("FAIL_FAST");
}
}
expect(
fullReleaseWorkflow.jobs.performance.steps.find(
(step: WorkflowStep) => step.id === "dispatch",
)?.env,
).not.toHaveProperty("FAIL_FAST");
expect(fullReleaseSource).toContain('-f fail_fast="$FAIL_FAST"');
expect(fullReleaseSource).not.toContain(
"has failed child jobs before the workflow completed; cancelling the remaining run.",
);
expect(fullReleaseSource).not.toContain("trap cancel_child");
expect(fullReleaseSource).not.toContain("cancel_child_on_failure");
expect(fullReleaseSource).not.toContain("exit_on_parent_signal");
expect(fullReleaseSource).not.toContain("disable_child_cleanup");
expect(fullReleaseSource).not.toContain("cancel_child");
expect(fullReleaseSource).toContain(
'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then',
);
expect(releaseChecksWorkflow.on.workflow_dispatch.inputs.fail_fast).toEqual({
description: "Stop the Matrix QA lane after its first failed check or scenario",
required: false,
default: false,
type: "boolean",
});
expect(releaseChecksWorkflow.jobs.qa_live_release_checks.with.fail_fast).toBe(
"${{ fromJSON(needs.resolve_target.outputs.fail_fast) }}",
);
const qaLiveSource = readFileSync(".github/workflows/qa-live-transports-convex.yml", "utf8");
expect(qaLiveSource).toContain('if [[ "$FAIL_FAST" == "true" ]]');
});
it("allows Unreleased notes only for current-tree release checks", () => {
const workflow = parse(readFileSync(".github/workflows/openclaw-release-checks.yml", "utf8"));
const fullReleaseWorkflow = readFullReleaseValidationWorkflow();
const resolveTarget = workflow.jobs.resolve_target;
const captureInputs = resolveTarget.steps.find(
(step: WorkflowStep) => step.name === "Capture selected inputs",
);
const currentTreeAllowance =
"${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }}";
expect(workflow.on.workflow_dispatch.inputs.allow_unreleased_changelog).toEqual({
default: false,
description: "Allow explicitly opted-in current-tree packaging to use Unreleased notes",
required: false,
type: "boolean",
});
expect(resolveTarget.outputs.allow_unreleased_changelog).toBe(
"${{ steps.inputs.outputs.allow_unreleased_changelog }}",
);
expect(captureInputs?.run).toContain('RELEASE_REF_INPUT" == "main"');
expect(captureInputs?.run).toContain('RELEASE_REF_INPUT" == "refs/heads/main"');
expect(captureInputs?.run).toContain("release/[0-9]{4}");
expect(captureInputs?.run).toContain("extended-stable/[0-9]{4}");
expect(captureInputs?.run).toContain("refs/tags/");
expect(captureInputs?.run).toContain("RELEASE_ALLOW_UNRELEASED_CHANGELOG_INPUT");
expect(captureInputs?.run).toContain("allow_unreleased_changelog=false");
const explicitOptIn = captureInputs?.run.indexOf('"$allow_unreleased_changelog" == "true"');
const releaseRefGuard = captureInputs?.run.indexOf(
'"$RELEASE_REF_INPUT" =~ ^(refs/heads/)?(release/',
);
expect(explicitOptIn).toBeGreaterThanOrEqual(0);
expect(releaseRefGuard).toBeGreaterThan(explicitOptIn ?? -1);
expect(workflow.jobs.install_smoke_release_checks.with.allow_unreleased_changelog).toBe(
currentTreeAllowance,
);
expect(workflow.jobs.live_repo_e2e_release_checks.with.allow_unreleased_changelog).toBe(
currentTreeAllowance,
);
expect(workflow.jobs.docker_e2e_release_checks.with.allow_unreleased_changelog).toBe(
currentTreeAllowance,
);
const fullReleaseAllowance =
"${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }}";
const summarizeTarget = fullReleaseWorkflow.jobs.resolve_target.steps.find(
(step: WorkflowStep) => step.name === "Summarize target",
);
const releaseChecksDispatch = fullReleaseWorkflow.jobs.release_checks_candidate.steps.find(
(step: WorkflowStep) => step.name === "Dispatch release checks candidate phase",
);
expect(summarizeTarget?.env?.ALLOW_UNRELEASED_CHANGELOG).toBe(fullReleaseAllowance);
expect(releaseChecksDispatch?.env?.ALLOW_UNRELEASED_CHANGELOG).toBe(fullReleaseAllowance);
});
it("keeps runtime tool coverage blocking in release checks", () => {
const releaseChecksSource = readFileSync(
".github/workflows/openclaw-release-checks.yml",
"utf8",
);
const releaseChecksWorkflow = parse(releaseChecksSource);
const runtimeToolCoverage = releaseChecksWorkflow.jobs.runtime_tool_coverage_release_checks;
expect(runtimeToolCoverage["continue-on-error"]).toBeUndefined();
expect(runtimeToolCoverage.needs).toEqual([
"resolve_target",
"qa_lab_runtime_parity_release_checks",
]);
expect(runtimeToolCoverage.steps).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Enforce core runtime tool coverage",
run: expect.stringContaining("pnpm openclaw qa coverage"),
}),
]),
);
expect(runtimeToolCoverage.steps).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Enforce core runtime tool coverage",
run: expect.stringContaining(
"--summary .artifacts/qa-e2e/runtime-pair-core/qa-suite-summary.json",
),
}),
]),
);
expect(releaseChecksWorkflow.jobs.summary.needs).toContain(
"runtime_tool_coverage_release_checks",
);
const verifyStep = releaseChecksWorkflow.jobs.summary.steps.find(
(step: { name?: string }) => step.name === "Verify release check results",
);
expect(verifyStep.env.RUNTIME_TOOL_COVERAGE_RELEASE_CHECKS_RESULT).toBe(
"${{ needs.runtime_tool_coverage_release_checks.result }}",
);
expect(verifyStep.run).toContain(
'"runtime_tool_coverage_release_checks=${RUNTIME_TOOL_COVERAGE_RELEASE_CHECKS_RESULT}"',
);
});
it("keeps the live-ish availability check redacted", () => {
const output = execFileSync(
process.execPath,
["--import", "tsx", "scripts/plugin-prerelease-liveish-matrix.mts"],
{
encoding: "utf8",
env: {
DISCORD_TOKEN: "discord-token-should-not-print",
OPENAI_API_KEY: "openai-token-should-not-print",
},
},
);
expect(output).toContain("provider-openai: present (OPENAI_API_KEY, OPENAI_BASE_URL)");
expect(output).toContain("channel-discord: present (DISCORD_TOKEN, OPENCLAW_DISCORD_TOKEN)");
expect(output).not.toContain("openai-token-should-not-print");
expect(output).not.toContain("discord-token-should-not-print");
});
});