fix(ci): support frozen script entrypoints (#121208)

* fix(ci): support frozen script entrypoints

* fix(ci): route frozen plugin tests through package script

* fix(release): support compiled candidate test helpers

* fix(release): support compiled upgrade helpers

* fix(release): mount trusted upgrade runtime

* fix(release): preserve trusted tsx resolution
This commit is contained in:
Peter Steinberger 2026-08-09 19:31:21 -07:00 • committed by GitHub
parent da0fad6718
commit 88fc335323
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 272 additions and 54 deletions

View file

@ -1339,7 +1339,7 @@ jobs:
if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" = "true" ]; then
start_check "gateway-watch" \
node --import tsx scripts/check-gateway-watch-regression.mts --skip-build
pnpm test:gateway:watch-regression -- --skip-build
fi
wait_checks
@ -1348,7 +1348,7 @@ jobs:
# starve the Gateway readiness deadline used by this regression gate.
if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" != "true" ]; then
start_check "gateway-watch" \
node --import tsx scripts/check-gateway-watch-regression.mts --skip-build
pnpm test:gateway:watch-regression -- --skip-build
wait_checks
fi
@ -1807,18 +1807,22 @@ jobs:
run: |
# Pack the public runtime before overlaying private QA artifacts so they cannot leak.
unset OPENCLAW_BUILD_PRIVATE_QA
node --import tsx scripts/build-all.mts qaRuntime
pnpm build qaRuntime
pnpm ui:build
package_args=(
--skip-build
--output-dir .artifacts/qa-e2e/smoke-ci-package
--output-name openclaw-current.tgz
)
if grep -Fq -- '--allow-unreleased-changelog' scripts/package-openclaw-for-docker.mts; then
package_script="scripts/package-openclaw-for-docker.mts"
if [[ ! -f "$package_script" ]]; then
package_script="scripts/package-openclaw-for-docker.mjs"
fi
if grep -Fq -- '--allow-unreleased-changelog' "$package_script"; then
package_args=(--allow-unreleased-changelog "${package_args[@]}")
fi
node scripts/package-openclaw-for-docker.mjs "${package_args[@]}"
OPENCLAW_BUILD_PRIVATE_QA=1 node --import tsx scripts/build-all.mts qaRuntime
OPENCLAW_BUILD_PRIVATE_QA=1 pnpm build qaRuntime
- name: Run smoke profile part
env:
@ -2638,7 +2642,11 @@ jobs:
case "$ADDITIONAL_CHECK_GROUP" in
boundaries)
node --import tsx scripts/run-additional-boundary-checks.mts
boundary_runner=(node --import tsx scripts/run-additional-boundary-checks.mts)
if [[ ! -f scripts/run-additional-boundary-checks.mts ]]; then
boundary_runner=(node scripts/run-additional-boundary-checks.mjs)
fi
"${boundary_runner[@]}"
;;
prompt-snapshots)
# No presence fallback: the boundary runner previously invoked
@ -2662,25 +2670,19 @@ jobs:
fi
;;
session-accessor-boundary)
if [ ! -f scripts/check-session-accessor-boundary.mts ]; then
echo "[skip] session accessor boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
if ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
echo "[skip] session accessor boundary script is not present in package.json"
else
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
fi
if [ ! -f scripts/check-sqlite-transaction-boundary.mts ]; then
echo "[skip] SQLite transaction boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:sqlite-transaction-boundary"] ? 0 : 1);'; then
if ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:sqlite-transaction-boundary"] ? 0 : 1);'; then
echo "[skip] SQLite transaction boundary script is not present in package.json"
else
run_check "lint:tmp:sqlite-transaction-boundary" pnpm run lint:tmp:sqlite-transaction-boundary
fi
;;
session-transcript-reader-boundary)
if [ ! -f scripts/check-session-transcript-reader-boundary.mts ]; then
echo "[skip] session transcript reader boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
if ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
echo "[skip] session transcript reader boundary script is not present in package.json"
else
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary

View file

@ -237,7 +237,7 @@ jobs:
OPENCLAW_BUILD_PRIVATE_QA: "1"
run: |
set -euo pipefail
node --import tsx scripts/build-all.mts qaRuntime
pnpm build qaRuntime
test -f dist/plugin-sdk/qa-runtime.js
test -f dist/extensions/qa-lab/runtime-api.js

View file

@ -1253,7 +1253,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Run parity lane
id: run_lane
@ -1434,7 +1434,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Generate parity report
id: generate_report
@ -1517,7 +1517,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Run runtime-pair lane
id: candidate_runtime_pair
@ -2127,7 +2127,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Run Discord live lane
id: run_lane
@ -2226,7 +2226,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Run WhatsApp live lane
id: run_lane
@ -2322,7 +2322,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Run Slack live lane
id: run_lane

View file

@ -477,7 +477,7 @@ jobs:
OPENCLAW_BUILD_PRIVATE_QA=1 \
PATH="$PATH" \
RUNNER_TEMP="$RUNNER_TEMP" \
pnpm exec node --import tsx scripts/build-all.mts qaRuntime
pnpm build qaRuntime
- name: Move built candidate outside trusted workspace
id: move_candidate
@ -539,7 +539,7 @@ jobs:
pnpm_version="$(pnpm --version)"
jq -n \
--arg archiveRoot "$archive_root" \
--arg buildCommand "node --import tsx scripts/build-all.mts qaRuntime" \
--arg buildCommand "pnpm build qaRuntime" \
--arg candidateSha "$TARGET_SHA" \
--arg candidateTree "$CANDIDATE_TREE" \
--arg nodeVersion "$node_version" \
@ -748,7 +748,7 @@ jobs:
.candidateSha == $candidateSha and
.candidateTree == $candidateTree and
.archiveRoot == "openclaw-telegram-candidate" and
.buildCommand == "node --import tsx scripts/build-all.mts qaRuntime" and
.buildCommand == "pnpm build qaRuntime" and
.sourceJob == "build_candidate"
' "$manifest_path" >/dev/null
[[ -d "$candidate_root/node_modules" && -f "$candidate_root/dist/index.js" ]]
@ -899,7 +899,7 @@ jobs:
id: build_harness
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node --import tsx scripts/build-all.mts qaRuntime
run: pnpm build qaRuntime
- name: Validate candidate artifact metadata
id: metadata

View file

@ -346,14 +346,10 @@ jobs:
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
}
const result = spawnSync(
"pnpm",
["exec", "node", "--import", "tsx", "scripts/test-projects.mts", ...configs],
{
env: childEnv,
stdio: "inherit",
},
);
const result = spawnSync("pnpm", ["test", "--", ...configs], {
env: childEnv,
stdio: "inherit",
});
process.exit(result.status ?? 1);
EOF

View file

@ -254,10 +254,27 @@ function relayChildStream(stream: Readable, label: string) {
};
}
export function resolveShardChildCommand(args: string[], nodeExecPath = process.execPath) {
const TEST_PROJECTS_ENTRYPOINTS = ["scripts/test-projects.mts", "scripts/test-projects.mjs"];
export function resolveTestProjectsEntrypoint(
fileExists: (path: string) => boolean = existsSync,
): string {
const entrypoint = TEST_PROJECTS_ENTRYPOINTS.find((candidate) => fileExists(candidate));
if (!entrypoint) {
throw new Error("CI target does not provide scripts/test-projects.mts or .mjs");
}
return entrypoint;
}
export function resolveShardChildCommand(
args: string[],
nodeExecPath = process.execPath,
testProjectsEntrypoint = resolveTestProjectsEntrypoint(),
) {
const loaderArgs = testProjectsEntrypoint.endsWith(".mts") ? ["--import", "tsx"] : [];
return {
command: nodeExecPath,
args: ["--import", "tsx", "scripts/test-projects.mts", ...args],
args: [...loaderArgs, testProjectsEntrypoint, ...args],
};
}

View file

@ -812,7 +812,14 @@ seed_state() {
}
apply_baseline_config_recipe() {
node --import tsx scripts/e2e/lib/upgrade-survivor/config-recipe.mts apply \
local tsx_import="${OPENCLAW_UPGRADE_SURVIVOR_TSX_IMPORT:-tsx}"
local recipe_runner=(
node --import "$tsx_import" scripts/e2e/lib/upgrade-survivor/config-recipe.mts
)
if [ ! -f scripts/e2e/lib/upgrade-survivor/config-recipe.mts ]; then
recipe_runner=(node scripts/e2e/lib/upgrade-survivor/config-recipe.mjs)
fi
"${recipe_runner[@]}" apply \
--summary "$CONFIG_COVERAGE_JSON" \
--baseline-version "$baseline_version"
}

View file

@ -157,6 +157,12 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
fi
OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"
TRUSTED_TSX_NODE_MODULES="$HARNESS_ROOT_DIR/node_modules"
TRUSTED_TSX_IMPORT="$TRUSTED_TSX_NODE_MODULES/tsx/dist/loader.mjs"
if [ ! -f "$TRUSTED_TSX_IMPORT" ]; then
echo "Trusted upgrade-survivor tsx loader not found: $TRUSTED_TSX_IMPORT" >&2
exit 1
fi
docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
@ -173,12 +179,14 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
-e OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT="$COMMAND_TIMEOUT" \
-e OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK="${OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK:-}" \
-e OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS="$ROOT_MANAGED_VPS" \
-e OPENCLAW_UPGRADE_SURVIVOR_TSX_IMPORT=/tmp/openclaw-release-harness/node_modules/tsx/dist/loader.mjs \
-e OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON=/tmp/openclaw-upgrade-survivor-artifacts/summary.json \
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \
-e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=/tmp/openclaw-clawhub-fixture-server.cjs \
"${PROBE_ENV_ARGS[@]}" \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
-v "$TRUSTED_TSX_NODE_MODULES:/tmp/openclaw-release-harness/node_modules:ro" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/clawhub-fixture-server.cjs:/tmp/openclaw-clawhub-fixture-server.cjs:ro" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/run.sh:/tmp/openclaw-upgrade-survivor-run.sh:ro" \
"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}" \

View file

@ -150,10 +150,34 @@ docker_e2e_build_or_reuse() {
return "$build_status"
}
docker_e2e_test_state_entrypoint() {
local extension entrypoint
for extension in mts mjs; do
entrypoint="$ROOT_DIR/scripts/lib/openclaw-test-state.$extension"
if [ -f "$entrypoint" ]; then
printf '%s\n' "$entrypoint"
return 0
fi
done
echo "OpenClaw test-state entrypoint not found under $ROOT_DIR/scripts/lib" >&2
return 1
}
docker_e2e_run_test_state() {
local entrypoint
entrypoint="$(docker_e2e_test_state_entrypoint)" || return
if [[ "$entrypoint" == *.mts ]]; then
node --import tsx "$entrypoint" "$@"
else
node "$entrypoint" "$@"
fi
}
docker_e2e_test_state_shell_b64() {
local label="${1:?missing test-state label}"
local scenario="${2:-empty}"
node --import tsx "$ROOT_DIR/scripts/lib/openclaw-test-state.mts" shell \
docker_e2e_run_test_state shell \
--label "$label" \
--scenario "$scenario" |
base64 |
@ -161,7 +185,7 @@ docker_e2e_test_state_shell_b64() {
}
docker_e2e_test_state_function_b64() {
node --import tsx "$ROOT_DIR/scripts/lib/openclaw-test-state.mts" shell-function |
docker_e2e_run_test_state shell-function |
base64 |
tr -d '\n'
}

View file

@ -18,6 +18,7 @@ import {
pruneFsModuleCache,
resolveShardChildCommand,
resolveShardPlans,
resolveTestProjectsEntrypoint,
runShardPlans,
} from "../../scripts/ci-run-node-test-shard.mts";
@ -36,13 +37,28 @@ afterEach(() => {
});
describe("scripts/ci-run-node-test-shard.mts", () => {
it("launches the child runner directly with Node", () => {
it("launches the current TypeScript child runner directly with Node", () => {
expect(resolveShardChildCommand(["one.config.ts"], "/runtime/node")).toEqual({
command: "/runtime/node",
args: ["--import", "tsx", "scripts/test-projects.mts", "one.config.ts"],
});
});
it("uses the compiled child runner from a frozen candidate", () => {
const entrypoint = resolveTestProjectsEntrypoint((candidate) => candidate.endsWith(".mjs"));
expect(entrypoint).toBe("scripts/test-projects.mjs");
expect(resolveShardChildCommand(["one.config.ts"], "/runtime/node", entrypoint)).toEqual({
command: "/runtime/node",
args: ["scripts/test-projects.mjs", "one.config.ts"],
});
});
it("fails clearly when the candidate has no test-projects entrypoint", () => {
expect(() => resolveTestProjectsEntrypoint(() => false)).toThrow(
"CI target does not provide scripts/test-projects.mts or .mjs",
);
});
it("prefers explicit targets and keeps one target per child", () => {
const plans = resolveShardPlans({
OPENCLAW_NODE_TEST_TARGETS_JSON: JSON.stringify(["a.test.ts", "b.test.ts"]),

View file

@ -5679,6 +5679,37 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
}
});
it("uses candidate-owned script interfaces for frozen target CI", () => {
const workflow = readCiWorkflow();
const buildChecks = workflow.jobs["build-artifacts"].steps.find(
(step: WorkflowStep) => step.name === "Run built artifact checks",
);
const qaBuild = workflow.jobs["qa-smoke-ci-profile"].steps.find(
(step: WorkflowStep) => step.name === "Build QA smoke runtime",
);
const additionalChecks = workflow.jobs["check-additional-shard"].steps.find(
(step: WorkflowStep) => step.name === "Run additional check shard",
);
expect(buildChecks.run).toContain("pnpm test:gateway:watch-regression -- --skip-build");
expect(buildChecks.run).not.toContain("scripts/check-gateway-watch-regression.mts");
expect(qaBuild.run.match(/pnpm build qaRuntime/gu)).toHaveLength(2);
expect(qaBuild.run).toContain('package_script="scripts/package-openclaw-for-docker.mts"');
expect(qaBuild.run).toContain('package_script="scripts/package-openclaw-for-docker.mjs"');
expect(additionalChecks.run).toContain(
"boundary_runner=(node --import tsx scripts/run-additional-boundary-checks.mts)",
);
expect(additionalChecks.run).toContain(
"boundary_runner=(node scripts/run-additional-boundary-checks.mjs)",
);
expect(additionalChecks.run).not.toContain(
"if [ ! -f scripts/check-session-accessor-boundary.mts ]",
);
expect(additionalChecks.run).not.toContain(
"if [ ! -f scripts/check-session-transcript-reader-boundary.mts ]",
);
});
it("emits one final CI gate after every selected lane", () => {
const workflow = readCiWorkflow();
const gate = workflow.jobs["ci-gate"];
@ -6689,25 +6720,19 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
expect(runStep.run).toContain("ci-routing)");
expect(fastCoreJob["runs-on"]).toContain("matrix.runner");
expect(smokeProfileJob.name).toBe("QA Smoke CI (${{ matrix.name }})");
const publicRuntimeBuild = smokeBuildStep.run.indexOf(
"node --import tsx scripts/build-all.mts qaRuntime",
);
const publicRuntimeBuild = smokeBuildStep.run.indexOf("pnpm build qaRuntime");
const uiBuild = smokeBuildStep.run.indexOf("pnpm ui:build");
const packageBuild = smokeBuildStep.run.indexOf("node scripts/package-openclaw-for-docker.mjs");
const privateRuntimeBuild = smokeBuildStep.run.lastIndexOf(
"node --import tsx scripts/build-all.mts qaRuntime",
"OPENCLAW_BUILD_PRIVATE_QA=1 pnpm build qaRuntime",
);
expect(smokeBuildStep.run).toContain("node --import tsx scripts/build-all.mts qaRuntime");
expect(smokeBuildStep.run).toContain("pnpm build qaRuntime");
expect(smokeBuildStep.run).toContain("pnpm ui:build");
expect(smokeBuildStep.env).not.toHaveProperty("OPENCLAW_BUILD_PRIVATE_QA");
expect(smokeBuildStep.run).toContain("unset OPENCLAW_BUILD_PRIVATE_QA");
expect(smokeBuildStep.run).toContain("--skip-build");
expect(smokeBuildStep.run).toContain(
"OPENCLAW_BUILD_PRIVATE_QA=1 node --import tsx scripts/build-all.mts qaRuntime",
);
expect(
smokeBuildStep.run.match(/node --import tsx scripts\/build-all\.mts qaRuntime/g),
).toHaveLength(2);
expect(smokeBuildStep.run).toContain("OPENCLAW_BUILD_PRIVATE_QA=1 pnpm build qaRuntime");
expect(smokeBuildStep.run.match(/pnpm build qaRuntime/g)).toHaveLength(2);
expect(smokeBuildStep.run).toContain("--allow-unreleased-changelog");
expect(smokeBuildStep.run).toContain("grep -Fq");
expect(smokeBuildStep.run).toContain('"${package_args[@]}"');

View file

@ -536,6 +536,38 @@ print_log_tail "$LOG_PATH"
);
});
it("resolves source and compiled candidate test-state entrypoints", () => {
const resolveEntrypoint = (rootDir: string) =>
spawnSync(
"bash",
["-c", `source "${DOCKER_E2E_IMAGE_HELPER_PATH}"; docker_e2e_test_state_entrypoint`],
{
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ROOT_DIR: rootDir },
},
);
const sourceResult = resolveEntrypoint(process.cwd());
expect(sourceResult.status, sourceResult.stderr).toBe(0);
expect(sourceResult.stdout.trim()).toBe(
join(process.cwd(), "scripts/lib/openclaw-test-state.mts"),
);
const compiledRoot = tempDirs.make("openclaw-compiled-test-state-");
const missingResult = resolveEntrypoint(compiledRoot);
expect(missingResult.status).toBe(1);
expect(missingResult.stderr).toContain("OpenClaw test-state entrypoint not found");
const compiledDir = join(compiledRoot, "scripts/lib");
mkdirSync(compiledDir, { recursive: true });
const compiledEntrypoint = join(compiledDir, "openclaw-test-state.mjs");
writeFileSync(compiledEntrypoint, "", "utf8");
const compiledResult = resolveEntrypoint(compiledRoot);
expect(compiledResult.status, compiledResult.stderr).toBe(0);
expect(compiledResult.stdout.trim()).toBe(compiledEntrypoint);
});
it("rejects malformed Docker E2E resource limits before a suite starts", () => {
const helper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8");
const scripts = [

View file

@ -312,6 +312,22 @@ describe("release Telegram QA workflow", () => {
});
expect(step("trusted_identity", "Verify dispatched-main identity").id).toBe("identity");
const candidateBuild = requireRun(
"build_candidate",
"Build candidate runtime without runner credentials",
);
expect(candidateBuild).toContain("pnpm build qaRuntime");
expect(candidateBuild).not.toContain("scripts/build-all.mts");
expect(requireRun("build_candidate", "Archive bounded candidate tree")).toContain(
'--arg buildCommand "pnpm build qaRuntime"',
);
expect(requireRun("attest_candidate", "Bounded extract and validate candidate")).toContain(
'.buildCommand == "pnpm build qaRuntime"',
);
expect(requireRun("run_telegram", "Build trusted QA harness").trim()).toBe(
"pnpm build qaRuntime",
);
const runJob = job("run_telegram");
expect(runJob.environment).toBe("qa-live-shared");
expect(runJob["timeout-minutes"]).toBe(60);

View file

@ -1768,7 +1768,7 @@ describe("package acceptance workflow", () => {
OPENCLAW_BUILD_PRIVATE_QA: "1",
});
expectTextToIncludeAll(buildPrivateQa.run, [
"node --import tsx scripts/build-all.mts qaRuntime",
"pnpm build qaRuntime",
"test -f dist/plugin-sdk/qa-runtime.js",
"test -f dist/extensions/qa-lab/runtime-api.js",
]);
@ -1810,6 +1810,10 @@ describe("package acceptance workflow", () => {
expect(releaseChecksWorkflow).toContain(
'codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}"',
);
expect(releaseChecksWorkflow.match(/run: pnpm build qaRuntime/gu)).toHaveLength(6);
expect(releaseChecksWorkflow).not.toContain(
"node --import tsx scripts/build-all.mts qaRuntime",
);
expect(releaseChecksWorkflow).toContain(
"codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}",
);

View file

@ -465,6 +465,11 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
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('spawnSync("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",

View file

@ -1,6 +1,14 @@
// Upgrade Survivor Config Recipe tests cover upgrade survivor config recipe script behavior.
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@ -16,8 +24,66 @@ import {
} from "../../scripts/e2e/lib/upgrade-survivor/config-recipe.mts";
const RECIPE_PATH = "scripts/e2e/lib/upgrade-survivor/config-recipe.mts";
const RUN_PATH = "scripts/e2e/lib/upgrade-survivor/run.sh";
const DOCKER_RUNNER_PATH = "scripts/e2e/upgrade-survivor-docker.sh";
describe("upgrade survivor config recipe command resolution", () => {
it("uses trusted tsx or the candidate compiled config recipe entrypoint", () => {
const runner = readFileSync(RUN_PATH, "utf8");
const dockerRunner = readFileSync(DOCKER_RUNNER_PATH, "utf8");
expect(runner).toContain("OPENCLAW_UPGRADE_SURVIVOR_TSX_IMPORT:-tsx");
expect(runner).toContain(
'node --import "$tsx_import" scripts/e2e/lib/upgrade-survivor/config-recipe.mts',
);
expect(runner).toContain(
"recipe_runner=(node scripts/e2e/lib/upgrade-survivor/config-recipe.mjs)",
);
expect(runner).toContain('"${recipe_runner[@]}" apply');
expect(dockerRunner).toContain(
'TRUSTED_TSX_IMPORT="$TRUSTED_TSX_NODE_MODULES/tsx/dist/loader.mjs"',
);
});
it("keeps trusted tsx dependencies resolvable at the Docker mount target", () => {
const dockerRunner = readFileSync(DOCKER_RUNNER_PATH, "utf8");
const loaderTarget = dockerRunner.match(
/OPENCLAW_UPGRADE_SURVIVOR_TSX_IMPORT=(\/tmp\/\S+\/loader\.mjs)/u,
)?.[1];
const mountTarget = dockerRunner.match(
/-v "\$TRUSTED_TSX_NODE_MODULES:(\/tmp\/[^:"]+):ro"/u,
)?.[1];
expect(loaderTarget).toBeTruthy();
expect(mountTarget).toBeTruthy();
if (!loaderTarget || !mountTarget) {
return;
}
const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-tsx-mount-"));
try {
const mountedNodeModules = join(root, mountTarget.slice(1));
mkdirSync(mountedNodeModules, { recursive: true });
for (const packageName of ["tsx", "esbuild", "@esbuild"]) {
cpSync(
join(process.cwd(), "node_modules", packageName),
join(mountedNodeModules, packageName),
{
dereference: true,
recursive: true,
},
);
}
const loaderPath = join(root, loaderTarget.slice(1));
const result = spawnSync(process.execPath, ["--import", loaderPath, "-e", ""], {
cwd: root,
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("compares baseline versions with the shared release parser", () => {
expect(isReleaseBefore("2026.3.31", "2026.4.0")).toBe(true);
expect(isReleaseBefore("2026.3.31-beta.1", "2026.4.0")).toBe(true);