Compare commits

...

9 Commits

Author SHA1 Message Date
Peter Steinberger
1824464bf2 fix(docker): avoid runtime prune hang 2026-05-12 16:15:39 +01:00
Peter Steinberger
6eebba3920 test(openai): relax live tts latency budget 2026-05-12 15:28:55 +01:00
Peter Steinberger
7b544a7976 fix(release): unblock docker release validation 2026-05-12 14:34:32 +01:00
Peter Steinberger
441041f92d test(release): harden beta validation flakes 2026-05-12 13:25:18 +01:00
Peter Steinberger
7284608461 fix(release): unblock update validation gates 2026-05-12 12:50:02 +01:00
Peter Steinberger
56d96b3b8d fix(release): unblock beta docker validation 2026-05-12 12:07:28 +01:00
Peter Steinberger
41bf26ede3 test(release): refresh beta validation expectations 2026-05-12 11:33:20 +01:00
Peter Steinberger
6820d18160 test(docker): align runtime prune assertion 2026-05-12 11:14:58 +01:00
Peter Steinberger
e6fb7aa1a8 fix(docker): allow runtime prune to hydrate cache 2026-05-12 11:06:35 +01:00
13 changed files with 213 additions and 32 deletions

View File

@@ -116,20 +116,25 @@ ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies and strip build-only metadata before copying
# Reinstall production dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
CI=true pnpm prune --prod \
--config.offline=true \
RUN --mount=type=cache,id=openclaw-pnpm-runtime-store,target=/root/.local/share/pnpm/store,sharing=locked \
echo "==> runtime-assets: install prod dependencies" && \
rm -rf node_modules && \
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
echo "==> runtime-assets: refresh bundled plugin registry" && \
node scripts/postinstall-bundled-plugins.mjs && \
echo "==> runtime-assets: prune non-selected plugin dist" && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
echo "==> runtime-assets: remove dist type and sourcemap files" && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
echo "==> runtime-assets: check package dist imports" && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────

View File

@@ -39,7 +39,7 @@ describe("nvidia onboard", () => {
legacyModelName: "Custom",
});
expect(provider?.models.map((model) => model.id)).toEqual([
"custom-model",
"nvidia/custom-model",
"nvidia/nemotron-3-super-120b-a12b",
"moonshotai/kimi-k2.5",
"minimaxai/minimax-m2.5",

View File

@@ -25,7 +25,7 @@ describeLive("openai tts live", () => {
cfg: { plugins: { enabled: true } } as never,
providerConfig,
target: "audio-file",
timeoutMs: 45_000,
timeoutMs: 90_000,
});
expect(audioFile.outputFormat).toBe("mp3");
expect(audioFile.fileExtension).toBe(".mp3");
@@ -35,10 +35,10 @@ describeLive("openai tts live", () => {
text: "OpenClaw OpenAI telephony integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig,
timeoutMs: 45_000,
timeoutMs: 90_000,
});
expect(telephony?.outputFormat).toBe("pcm");
expect(telephony?.sampleRate).toBe(24_000);
expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512);
}, 60_000);
}, 120_000);
});

View File

@@ -163,7 +163,7 @@ function createLiveTtsConfig(): ResolvedTtsConfig {
},
personas: {},
maxTextLength: 4_000,
timeoutMs: 30_000,
timeoutMs: 90_000,
};
}
@@ -288,7 +288,7 @@ describeLive("openai plugin live", () => {
expect(telephony?.outputFormat).toBe("pcm");
expect(telephony?.sampleRate).toBe(24_000);
expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512);
}, 45_000);
}, 120_000);
it("transcribes synthesized speech through the registered media provider", async () => {
const { speechProviders, mediaProviders } = await registerOpenAIPlugin();

View File

@@ -1176,6 +1176,7 @@ describe("createTelegramBot", () => {
]),
});
const storePath = `/tmp/openclaw-telegram-model-display-names-${process.pid}-${Date.now()}.json`;
const config = {
agents: {
defaults: {
@@ -1188,8 +1189,12 @@ describe("createTelegramBot", () => {
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
await rm(storePath, { force: true });
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
@@ -1234,6 +1239,7 @@ describe("createTelegramBot", () => {
[{ text: "<< Back", callback_data: "mdl_back" }],
]);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-display-names-1");
await rm(storePath, { force: true });
});
it("resets overrides when selecting the configured default model", async () => {

View File

@@ -10,20 +10,29 @@ minimumReleaseAgeExclude:
- "acpx"
- "tokenjuice"
- "@agentclientprotocol/sdk"
- "@anthropic-ai/sdk"
- "axios"
- "basic-ftp"
- "hono"
- "openclaw"
- "protobufjs"
- "playwright-core"
- "vite"
- "vitest"
- "@vitest/*"
- "@copilotkit/aimock"
- "yaml"
- "@cloudflare/workers-types"
- "@hono/node-server"
- "@mariozechner/*"
- "@openclaw/fs-safe"
- "@openai/codex"
- "@openai/codex-*"
- "@smithy/*"
- "@typescript/native-preview*"
- "@types/node"
- "@rolldown/*"
- "oxlint"
- "@oxlint/*"
- "@oxfmt/*"
- "oxfmt"

View File

@@ -48,20 +48,15 @@ export OPENCLAW_NO_PROMPT=1
package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}"
git_root="/tmp/openclaw-git"
mkdir -p "$git_root"
echo "==> prepare package-derived git fixture"
# Build the fake git install from the packed package contents, not the checkout.
tar -xzf "$package_tgz" -C "$git_root" --strip-components=1
# The package-derived fixture can carry patchedDependencies whose targets are
# absent from the trimmed tarball install; that should not block update preflight.
node scripts/e2e/lib/update-channel-switch/assertions.mjs prepare-git-fixture "$git_root"
(
cd "$git_root"
if ! npm install --omit=optional --no-fund --no-audit >/tmp/openclaw-git-install.log 2>&1; then
cat /tmp/openclaw-git-install.log >&2 || true
exit 1
fi
)
node scripts/e2e/lib/update-channel-switch/assertions.mjs write-control-ui "$git_root"
echo "==> commit package-derived git fixture"
git config --global user.email "docker-e2e@openclaw.local"
git config --global user.name "OpenClaw Docker E2E"
git config --global gc.auto 0
@@ -74,6 +69,7 @@ fixture_sha="$(git -C "$git_root" rev-parse HEAD)"
pkg_tgz_path="$package_tgz"
echo "==> install package fixture"
npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path"
package_version="$(node -p "JSON.parse(require(\"node:fs\").readFileSync(\"/tmp/npm-prefix/lib/node_modules/openclaw/package.json\", \"utf8\")).version")"
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(

View File

@@ -187,7 +187,7 @@ openclaw_live_link_runtime_tree "$tmp_dir"
openclaw_live_stage_state_dir "$tmp_dir/.openclaw-state"
openclaw_live_prepare_staged_config
cd "$tmp_dir"
pnpm test:live:models-profiles
pnpm_config_verify_deps_before_run=false pnpm test:live:models-profiles
EOF
OPENCLAW_LIVE_DOCKER_REPO_ROOT="$ROOT_DIR" "$TRUSTED_HARNESS_DIR/scripts/test-live-build-docker.sh"

View File

@@ -917,6 +917,7 @@ describe("update-cli", () => {
it("keeps downgrade post-update work in the current process", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
mockPackageInstallStatus(downgradedRoot);
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
@@ -958,6 +959,68 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it("same-process package updates avoid stale base hashes for plugin config writes", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
mockPackageInstallStatus(downgradedRoot);
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root: downgradedRoot,
before: { version: "2026.4.14" },
after: { version: "2026.4.10" },
}),
});
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
parsed: { plugins: { entries: {} } },
resolved: { plugins: { entries: {} } } as OpenClawConfig,
sourceConfig: { plugins: { entries: {} } } as OpenClawConfig,
runtimeConfig: { plugins: { entries: {} } } as OpenClawConfig,
config: { plugins: { entries: {} } } as OpenClawConfig,
hash: "pre-update-hash",
});
readPackageVersion.mockResolvedValue("2026.4.14");
syncPluginsForUpdateChannel.mockImplementationOnce(async ({ config }) => ({
changed: true,
config: {
...config,
plugins: {
...config.plugins,
entries: {
demo: { enabled: true },
},
},
} as OpenClawConfig,
summary: {
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
}));
updateNpmInstalledPlugins.mockImplementationOnce(async ({ config }) => ({
changed: false,
config,
outcomes: [],
}));
await updateCommand({ yes: true, tag: "2026.4.10", restart: false });
expect(spawn).not.toHaveBeenCalled();
const pluginWrite = vi.mocked(replaceConfigFile).mock.calls.at(-1)?.[0];
expect(pluginWrite).toMatchObject({
nextConfig: {
plugins: {
entries: {
demo: { enabled: true },
},
},
},
});
expect(pluginWrite).not.toHaveProperty("baseHash");
});
it("fails the update when the fresh process exits non-zero", async () => {
setupUpdatedRootRefresh();
spawn.mockImplementationOnce(() => {
@@ -1168,6 +1231,63 @@ describe("update-cli", () => {
expect(syncPluginCall()?.config?.update?.channel).toBe("dev");
});
it("post-core resume mode avoids stale base hashes for plugin config writes", async () => {
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
parsed: { plugins: { entries: {} } },
resolved: { plugins: { entries: {} } } as OpenClawConfig,
sourceConfig: { plugins: { entries: {} } } as OpenClawConfig,
runtimeConfig: { plugins: { entries: {} } } as OpenClawConfig,
config: { plugins: { entries: {} } } as OpenClawConfig,
hash: "pre-post-core-hash",
});
syncPluginsForUpdateChannel.mockImplementationOnce(async ({ config }) => ({
changed: true,
config: {
...config,
plugins: {
...config.plugins,
entries: {
demo: { enabled: true },
},
},
} as OpenClawConfig,
summary: {
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
}));
updateNpmInstalledPlugins.mockImplementationOnce(async ({ config }) => ({
changed: false,
config,
outcomes: [],
}));
await withEnvAsync(
{
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
},
async () => {
await updateCommand({ restart: false });
},
);
const pluginWrite = vi.mocked(replaceConfigFile).mock.calls.at(-1)?.[0];
expect(pluginWrite).toMatchObject({
nextConfig: {
plugins: {
entries: {
demo: { enabled: true },
},
},
},
});
expect(pluginWrite).not.toHaveProperty("baseHash");
});
it("passes the update timeout budget into post-core plugin updates", async () => {
await withEnvAsync(
{

View File

@@ -1156,6 +1156,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
root: string;
channel: "stable" | "beta" | "dev";
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
updateMode?: UpdateRunResult["mode"];
opts: UpdateCommandOptions;
timeoutMs: number;
pluginInstallRecords?: Record<string, PluginInstallRecord>;
@@ -1378,7 +1379,11 @@ export async function updatePluginsAfterCoreUpdate(params: {
previousInstallRecords: pluginInstallRecords,
nextInstallRecords,
nextConfig,
baseHash: params.configSnapshot.hash,
baseHash:
process.env[POST_CORE_UPDATE_ENV] === "1" ||
(params.updateMode ? isPackageManagerUpdateMode(params.updateMode) : false)
? undefined
: params.configSnapshot.hash,
});
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
@@ -1738,6 +1743,7 @@ async function runPostCorePluginUpdate(params: {
root: string;
channel: "stable" | "beta" | "dev";
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
updateMode?: UpdateRunResult["mode"];
opts: UpdateCommandOptions;
timeoutMs: number;
pluginInstallRecords?: Record<string, PluginInstallRecord>;
@@ -1746,6 +1752,7 @@ async function runPostCorePluginUpdate(params: {
root: params.root,
channel: params.channel,
configSnapshot: params.configSnapshot,
updateMode: params.updateMode,
opts: params.opts,
timeoutMs: params.timeoutMs,
pluginInstallRecords: params.pluginInstallRecords,
@@ -2533,6 +2540,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
root: postUpdateRoot,
channel,
configSnapshot: postUpdateConfigSnapshot,
updateMode: result.mode,
opts,
timeoutMs: updateStepTimeoutMs,
pluginInstallRecords: preUpdatePluginInstallRecords,

View File

@@ -88,12 +88,15 @@ describe("Dockerfile", () => {
expect(dockerfile).toContain("apt-get install -y --no-install-recommends xvfb");
});
it("uses the Docker target platform for pnpm install and prune", async () => {
it("uses the Docker target platform for pnpm install and runtime dependency install", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain("pnpm install --frozen-lockfile \\");
expect(dockerfile).toContain("CI=true pnpm prune --prod \\");
expect(dockerfile).toContain("--config.offline=true");
expect(dockerfile).toContain(
"NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts \\",
);
expect(dockerfile).not.toContain("pnpm prune --prod");
expect(dockerfile).not.toContain("--config.offline=true");
expect(dockerfile.split("--config.supportedArchitectures.os=linux").length - 1).toBe(2);
expect(
dockerfile.split("--config.supportedArchitectures.cpu=\"$(node -p 'process.arch')\"").length -
@@ -156,7 +159,7 @@ describe("Dockerfile", () => {
expect(dockerfile).toContain("pnpm_config_verify_deps_before_run=false pnpm qa:lab:build");
});
it("prunes runtime dependencies after the build stage", async () => {
it("installs runtime dependencies after the build stage", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain("FROM build AS runtime-assets");
expect(dockerfile).toContain("ARG OPENCLAW_EXTENSIONS");
@@ -168,14 +171,19 @@ describe("Dockerfile", () => {
'Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" .',
);
expect(dockerfile).toContain(
"RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \\",
"RUN --mount=type=cache,id=openclaw-pnpm-runtime-store,target=/root/.local/share/pnpm/store,sharing=locked \\",
);
expect(dockerfile).toContain("COPY --from=workspace-deps /out/packages/ ./packages/");
expect(dockerfile).toContain(
"COPY --from=workspace-deps /out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/",
);
expect(dockerfile).toContain("CI=true pnpm prune --prod \\");
expect(dockerfile).toContain("--config.offline=true");
expect(dockerfile).toContain("runtime-assets: install prod dependencies");
expect(dockerfile).toContain("rm -rf node_modules");
expect(dockerfile).toContain(
"NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts \\",
);
expect(dockerfile).not.toContain("pnpm prune --prod");
expect(dockerfile).not.toContain("--config.offline=true");
expect(dockerfile).toContain("--config.supportedArchitectures.os=linux");
expect(dockerfile).toContain(
"--config.supportedArchitectures.cpu=\"$(node -p 'process.arch')\"",
@@ -205,7 +213,8 @@ describe("Dockerfile", () => {
const pnpmWorkspace = YAML.parse(await readFile(pnpmWorkspacePath, "utf8")) as {
patchedDependencies?: Record<string, string>;
};
const pruneProd = "CI=true pnpm prune --prod";
const runtimeInstall =
"NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts";
const finalWorkspaceCopy =
"COPY --from=runtime-assets --chown=node:node /app/pnpm-workspace.yaml .";
@@ -214,7 +223,7 @@ describe("Dockerfile", () => {
expect(dockerfile).not.toContain("write-runtime-pnpm-workspace");
expect(dockerfile).not.toContain("pnpm_config_frozen_lockfile=false");
expect(dockerfile).toContain(finalWorkspaceCopy);
expect(dockerfile.indexOf(pruneProd)).toBeLessThan(dockerfile.indexOf(finalWorkspaceCopy));
expect(dockerfile.indexOf(runtimeInstall)).toBeLessThan(dockerfile.indexOf(finalWorkspaceCopy));
expect(dockerfile).toContain(
"COPY --from=runtime-assets --chown=node:node /app/pnpm-workspace.yaml .",
);

View File

@@ -38,9 +38,25 @@ async function addCompileCacheProbe(fixtureRoot: string): Promise<void> {
);
}
async function waitForFile(filePath: string, timeoutMs: number): Promise<string> {
function isJsonContent(content: string): boolean {
try {
return await fs.readFile(filePath, "utf8");
JSON.parse(content);
return true;
} catch {
return false;
}
}
async function waitForFile(
filePath: string,
timeoutMs: number,
isReady: (content: string) => boolean = () => true,
): Promise<string> {
try {
const content = await fs.readFile(filePath, "utf8");
if (isReady(content)) {
return content;
}
} catch {
// Wait below.
}
@@ -59,8 +75,15 @@ async function waitForFile(filePath: string, timeoutMs: number): Promise<string>
watcher?.close();
};
const tryRead = async () => {
if (settled) {
return;
}
try {
const content = await fs.readFile(filePath, "utf8");
if (!isReady(content)) {
setTimeout(() => void tryRead(), 10);
return;
}
cleanup();
resolve(content);
} catch {
@@ -250,7 +273,9 @@ describe("openclaw launcher", () => {
let respawnChildPid: number | undefined;
try {
const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number };
const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000, isJsonContent)) as {
pid: number;
};
respawnChildPid = childInfo.pid;
launcher.kill("SIGTERM");
@@ -301,7 +326,9 @@ describe("openclaw launcher", () => {
let respawnChildPid: number | undefined;
try {
const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number };
const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000, isJsonContent)) as {
pid: number;
};
respawnChildPid = childInfo.pid;
launcher.kill("SIGTERM");

View File

@@ -253,6 +253,7 @@ function buildDockerE2eHarnessEntries(): Record<string, string> {
"agents/pi-bundle-mcp-runtime": "src/agents/pi-bundle-mcp-runtime.ts",
"agents/pi-embedded-runner/effective-tool-policy":
"src/agents/pi-embedded-runner/effective-tool-policy.ts",
"agents/pi-embedded-runner/tool-split": "src/agents/pi-embedded-runner/tool-split.ts",
"agents/pi-embedded-runner/run/runtime-context-prompt":
"src/agents/pi-embedded-runner/run/runtime-context-prompt.ts",
"auto-reply/reply/commands-crestodian": "src/auto-reply/reply/commands-crestodian.ts",