fix: keep frozen Docker planning dependency-free (#108340)

* fix(release): keep frozen Docker planning dependency-free

* fix(release): gate frozen scenario command execution
This commit is contained in:
Peter Steinberger
2026-07-15 08:15:13 -07:00
committed by GitHub
parent b2e42e3645
commit 688a129ed0
7 changed files with 293 additions and 120 deletions

View File

@@ -2,6 +2,7 @@
import { spawn, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -47,6 +48,16 @@ const { createTempDir } = createScriptTestHarness();
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
function writeFrozenScenarioContract(root: string, scenarios: string[]): string {
const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(path.dirname(assertionsFile), { recursive: true });
writeFileSync(
assertionsFile,
`process.stdout.write(${JSON.stringify(`${JSON.stringify(scenarios)}\n`)});\n`,
);
return assertionsFile;
}
function expectDeclaredDispatchInputs(command: string): void {
const workflow = parse(readFileSync(LIVE_E2E_WORKFLOW, "utf8")) as {
on?: { workflow_dispatch?: { inputs?: Record<string, unknown> } };
@@ -152,6 +163,35 @@ describe("scripts/test-docker-all scheduler", () => {
expect(result.stderr).not.toContain("at ");
});
it("plans from an isolated release harness without installed dependencies", () => {
const root = tempDirs.make("openclaw-docker-plan-isolated-harness-");
const scriptsDir = path.join(root, "scripts");
const libDir = path.join(scriptsDir, "lib");
mkdirSync(libDir, { recursive: true });
copyFileSync("scripts/test-docker-all.mjs", path.join(scriptsDir, "test-docker-all.mjs"));
for (const fileName of ["docker-e2e-plan.mjs", "docker-e2e-scenarios.mjs", "sleep.mjs"]) {
copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName));
}
const result = spawnSync(
process.execPath,
[path.join(scriptsDir, "test-docker-all.mjs"), "--plan-json"],
{
cwd: root,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL: "1",
OPENCLAW_DOCKER_ALL_PROFILE: "release-path",
OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT: process.cwd(),
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ profile: "release-path" });
});
it("rejects loose numeric runner env vars without a stack trace", () => {
const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs", "--plan-json"], {
cwd: process.cwd(),
@@ -305,10 +345,17 @@ describe("scripts/test-docker-all scheduler", () => {
it("rejects candidate-controlled survivor omissions without trusted opt-in", () => {
const root = tempDirs.make("openclaw-docker-all-untrusted-filter-");
const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
try {
mkdirSync(path.dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
const assertionsFile = writeFrozenScenarioContract(root, ["unrelated"]);
const executionMarker = path.join(root, "candidate-contract-executed");
writeFileSync(
assertionsFile,
[
'import { writeFileSync } from "node:fs";',
`writeFileSync(${JSON.stringify(executionMarker)}, "executed");`,
'process.stdout.write("[\\"unrelated\\"]\\n");',
].join("\n"),
);
const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], {
cwd: process.cwd(),
encoding: "utf8",
@@ -325,6 +372,7 @@ describe("scripts/test-docker-all scheduler", () => {
expect(result.status).toBe(1);
expect(result.stderr).toContain("require trusted workflow opt-in");
expect(result.stdout).not.toContain("Dry run complete");
expect(existsSync(executionMarker)).toBe(false);
} finally {
rmSync(root, { force: true, recursive: true });
}
@@ -333,10 +381,8 @@ describe("scripts/test-docker-all scheduler", () => {
it("writes a passing summary when a frozen target cannot run selected survivor lanes", () => {
const root = tempDirs.make("openclaw-docker-all-filtered-");
const logDir = path.join(root, "logs");
const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
try {
mkdirSync(path.dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(root, ["unrelated"]);
const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], {
cwd: process.cwd(),
encoding: "utf8",
@@ -369,10 +415,8 @@ describe("scripts/test-docker-all scheduler", () => {
it("reports omitted frozen-target lanes when another selected lane remains runnable", () => {
const root = tempDirs.make("openclaw-docker-all-mixed-filtered-");
const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
try {
mkdirSync(path.dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(root, ["unrelated"]);
const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], {
cwd: process.cwd(),
encoding: "utf8",

View File

@@ -22,10 +22,25 @@ const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
scripts?: Record<string, string>;
};
function writeFrozenScenarioContract(targetRoot: string, scenarios: string[]): string {
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(
assertionsFile,
[
`const scenarios = ${JSON.stringify(scenarios)};`,
'if (process.argv[2] !== "list-scenarios") throw new Error("unknown command");',
"process.stdout.write(`${JSON.stringify(scenarios)}\\n`);",
].join("\n"),
);
return assertionsFile;
}
function planFor(
overrides: Partial<Parameters<typeof resolveDockerE2ePlan>[0]> = {},
): ReturnType<typeof resolveDockerE2ePlan>["plan"] {
return resolveDockerE2ePlan({
allowFrozenTargetScenarioOmissions: true,
includeOpenWebUI: false,
liveMode: "all",
liveRetries: DEFAULT_LIVE_RETRIES,
@@ -772,24 +787,17 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("omits trusted-current scenarios unsupported by a frozen target harness", () => {
const targetRoot = tempDirs.make("openclaw-frozen-upgrade-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(
assertionsFile,
`const SCENARIOS = new Set([\n${[
"base",
"feishu-channel",
"bootstrap-persona",
"channel-post-core-restore",
"plugin-deps-cleanup",
"configured-plugin-installs",
"stale-source-plugin-shadow",
"tilde-log-path",
"versioned-runtime-deps",
]
.map((scenario) => `'${scenario}'`)
.join(",\n")}\n]);\nconst unrelated = "acpx-openclaw-tools-bridge";\n`,
);
writeFrozenScenarioContract(targetRoot, [
"base",
"feishu-channel",
"bootstrap-persona",
"channel-post-core-restore",
"plugin-deps-cleanup",
"configured-plugin-installs",
"stale-source-plugin-shadow",
"tilde-log-path",
"versioned-runtime-deps",
]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.6.11",
@@ -813,11 +821,46 @@ describe("scripts/lib/docker-e2e-plan", () => {
]);
});
it("reads content-addressed scenario catalogs from pre-command frozen targets", () => {
const targetRoot = tempDirs.make("openclaw-legacy-frozen-upgrade-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
const legacyScenarios = [
"base",
"feishu-channel",
"bootstrap-persona",
"channel-post-core-restore",
"plugin-deps-cleanup",
"configured-plugin-installs",
"stale-source-plugin-shadow",
"tilde-log-path",
"versioned-runtime-deps",
];
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(
assertionsFile,
[
"const SCENARIOS = new Set([",
...legacyScenarios.map((scenario) => ` "${scenario}",`),
"]);",
'throw new Error("unknown upgrade-survivor assertion command: list-scenarios");',
].join("\n"),
);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.6.11",
upgradeSurvivorScenarios: "reported-issues",
upgradeSurvivorTargetRoot: targetRoot,
});
expect(plan.omittedUnsupportedLanes).toEqual([
"published-upgrade-survivor-2026.6.11-acpx-openclaw-tools-bridge",
]);
});
it("omits survivor lanes when the target exposes none of the requested scenarios", () => {
const targetRoot = tempDirs.make("openclaw-frozen-empty-upgrade-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
@@ -836,9 +879,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("omits baseline-only survivor lanes when the target lacks the implicit base scenario", () => {
const targetRoot = tempDirs.make("openclaw-frozen-no-base-upgrade-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
@@ -852,9 +893,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("omits an unconfigured survivor lane when the target lacks the implicit base scenario", () => {
const targetRoot = tempDirs.make("openclaw-frozen-default-base-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
@@ -867,9 +906,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("reports an unsupported survivor lane beside runnable selected lanes", () => {
const targetRoot = tempDirs.make("openclaw-frozen-mixed-upgrade-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor", "plugin-binding-command-escape"],
@@ -887,9 +924,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("reports an explicitly selected expanded survivor lane as unsupported", () => {
const targetRoot = tempDirs.make("openclaw-frozen-expanded-upgrade-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const selectedLane = "published-upgrade-survivor-2026.6.11";
const plan = planFor({
@@ -904,9 +939,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("omits unsupported scenario-only survivor lanes without explicit baselines", () => {
const targetRoot = tempDirs.make("openclaw-frozen-scenario-only-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
@@ -919,9 +952,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
it("does not fall back to base when an unsupported scenario is baseline-incompatible", () => {
const targetRoot = tempDirs.make("openclaw-frozen-incompatible-scenario-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n');
writeFrozenScenarioContract(targetRoot, ["unrelated"]);
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
@@ -934,13 +965,16 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.omittedUnsupportedLanes).toEqual([]);
});
it("fails closed when a frozen target scenario contract is not a literal set", () => {
const targetRoot = tempDirs.make("openclaw-frozen-indirect-scenario-harness-");
it("fails closed when an unknown legacy scenario catalog lacks the command", () => {
const targetRoot = tempDirs.make("openclaw-frozen-failed-scenario-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(
assertionsFile,
'const supported = ["base"];\nconst SCENARIOS = new Set(supported);\n',
[
'const SCENARIOS = new Set(["base"]);',
'throw new Error("unknown upgrade-survivor assertion command: list-scenarios");',
].join("\n"),
);
expect(() =>
@@ -949,17 +983,44 @@ describe("scripts/lib/docker-e2e-plan", () => {
upgradeSurvivorScenarios: "reported-issues",
upgradeSurvivorTargetRoot: targetRoot,
}),
).toThrow("expected const SCENARIOS = new Set([<string literals>])");
).toThrow("unknown upgrade-survivor assertion command: list-scenarios");
});
it("fails closed when a frozen target scenario command returns non-JSON output", () => {
const targetRoot = tempDirs.make("openclaw-frozen-non-json-scenario-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'process.stdout.write("base");\n');
expect(() =>
planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorScenarios: "reported-issues",
upgradeSurvivorTargetRoot: targetRoot,
}),
).toThrow("list-scenarios did not return JSON");
});
it("fails closed when a frozen target scenario command returns an invalid catalog", () => {
const targetRoot = tempDirs.make("openclaw-frozen-invalid-scenario-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'process.stdout.write("[\\"base\\",\\"base\\"]");\n');
expect(() =>
planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorScenarios: "reported-issues",
upgradeSurvivorTargetRoot: targetRoot,
}),
).toThrow("list-scenarios returned an invalid catalog");
});
it("does not inspect a frozen survivor contract for unrelated selected lanes", () => {
const targetRoot = tempDirs.make("openclaw-frozen-unrelated-lane-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(
assertionsFile,
'const supported = ["base"];\nconst SCENARIOS = new Set(supported);\n',
);
writeFileSync(assertionsFile, 'throw new Error("must not run");\n');
const plan = planFor({
selectedLaneNames: ["plugin-binding-command-escape"],
@@ -971,21 +1032,6 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.omittedUnsupportedLanes).toEqual([]);
});
it("fails closed when a frozen target scenario contract is mutable", () => {
const targetRoot = tempDirs.make("openclaw-frozen-mutable-scenario-harness-");
const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
mkdirSync(dirname(assertionsFile), { recursive: true });
writeFileSync(assertionsFile, 'let SCENARIOS = new Set(["base"]);\n');
expect(() =>
planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorScenarios: "reported-issues",
upgradeSurvivorTargetRoot: targetRoot,
}),
).toThrow("expected const SCENARIOS = new Set([<string literals>])");
});
it("skips plugin dependency cleanup for baselines without packaged plugin dirs", () => {
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],

View File

@@ -155,6 +155,18 @@ function assertConfiguredPluginState(params: { installPath?: string } = {}): voi
}
describe("upgrade survivor assertions", () => {
it("lists the dependency-free scenario contract", () => {
const scenarios = JSON.parse(
execFileSync(process.execPath, [ASSERTIONS_PATH, "list-scenarios"], {
encoding: "utf8",
}),
) as string[];
expect(scenarios).toContain("base");
expect(scenarios).toContain("acpx-openclaw-tools-bridge");
expect(new Set(scenarios).size).toBe(scenarios.length);
});
it("accepts the ACPX OpenClaw tools bridge scenario during seed", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-"));
try {