Skip to content

Commit 8755077

Browse files
vincentkocsteipete
andauthored
fix(release): bundle AI runtime in installer smoke (#103556)
* fix(release): bundle AI runtime in installer smoke * fix(release): verify bundled AI runtime loadability * fix(release): preserve advisory doctor exits --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 96ec604 commit 8755077

8 files changed

Lines changed: 686 additions & 177 deletions

File tree

.github/workflows/install-smoke.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -410,10 +410,17 @@ jobs:
410410
DOCKER_BUILD_SUMMARY: "false"
411411
DOCKER_BUILD_RECORD_UPLOAD: "false"
412412
steps:
413-
- name: Checkout CLI
413+
- name: Checkout trusted installer harness
414+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
415+
with:
416+
ref: ${{ github.workflow_sha }}
417+
persist-credentials: false
418+
419+
- name: Checkout candidate CLI
414420
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
415421
with:
416422
ref: ${{ inputs.ref || github.ref }}
423+
path: candidate
417424
persist-credentials: false
418425

419426
- name: Log in to GHCR
@@ -471,6 +478,7 @@ jobs:
471478
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: ${{ inputs.update_baseline_version || 'latest' }}
472479
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: ${{ needs.root_dockerfile_image.outputs.image_ref }}
473480
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
481+
OPENCLAW_INSTALL_SMOKE_SOURCE_DIR: ${{ github.workspace }}/candidate
474482
run: bash scripts/test-install-sh-docker.sh
475483

476484
- name: Run Rocky Linux installer smoke
@@ -479,7 +487,7 @@ jobs:
479487
--platform linux/amd64 \
480488
-e OPENCLAW_NO_ONBOARD=1 \
481489
-e OPENCLAW_NO_PROMPT=1 \
482-
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
490+
-v "$PWD/candidate/scripts/install.sh:/tmp/install.sh:ro" \
483491
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
484492
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
485493
@@ -489,7 +497,7 @@ jobs:
489497
--platform linux/amd64 \
490498
-e OPENCLAW_NO_ONBOARD=1 \
491499
-e OPENCLAW_NO_PROMPT=1 \
492-
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
500+
-v "$PWD/candidate/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
493501
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
494502
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
495503

scripts/check-openclaw-package-tarball.mjs

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fs from "node:fs";
77
import os from "node:os";
88
import path from "node:path";
99
import { performance } from "node:perf_hooks";
10+
import { pathToFileURL } from "node:url";
1011
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs";
1112
import {
1213
collectPackageDistImports,
@@ -73,6 +74,22 @@ const PACKAGE_DEPENDENCY_SECTIONS = [
7374
"devDependencies",
7475
];
7576
const REQUIRED_BUNDLED_WORKSPACE_DEPENDENCIES = ["@openclaw/ai"];
77+
// Strict Docker artifacts bundle this private runtime rather than resolving it
78+
// from npm. Keep the concrete load-bearing entries explicit instead of
79+
// reimplementing Node's conditional package-exports resolver here.
80+
const REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES = new Map([
81+
[
82+
"@openclaw/ai",
83+
[
84+
{ specifier: "@openclaw/ai", entry: "dist/index.mjs" },
85+
{ specifier: "@openclaw/ai/providers", entry: "dist/providers.mjs" },
86+
{
87+
specifier: "@openclaw/ai/internal/runtime",
88+
entry: "dist/internal/runtime.mjs",
89+
},
90+
],
91+
],
92+
]);
7693

7794
function collectWorkspaceProtocolDependencyErrors(packageJson, label) {
7895
const errors = [];
@@ -111,7 +128,96 @@ function listBundleDependencies(packageJson) {
111128
: [];
112129
}
113130

114-
function collectRequiredBundledWorkspaceDependencyErrors(packageJson, entrySet) {
131+
function resolveBundledPackageSpecifiers(packageRoot, specifiers) {
132+
const result = spawnSync(
133+
process.execPath,
134+
[
135+
"--input-type=module",
136+
"--eval",
137+
`const resolutions = {};
138+
for (const specifier of JSON.parse(process.argv[1])) {
139+
try {
140+
resolutions[specifier] = import.meta.resolve(specifier);
141+
} catch {
142+
resolutions[specifier] = "";
143+
}
144+
}
145+
process.stdout.write(JSON.stringify(resolutions));`,
146+
JSON.stringify(specifiers),
147+
],
148+
{ cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
149+
);
150+
if (result.status !== 0) {
151+
return null;
152+
}
153+
try {
154+
return JSON.parse(result.stdout);
155+
} catch {
156+
return null;
157+
}
158+
}
159+
160+
function collectBundledPackageRuntimeErrors({ name, entries, files, packageRoot, readText }) {
161+
const errors = [];
162+
const packagePrefix = `node_modules/${name}/`;
163+
const manifestPath = `${packagePrefix}package.json`;
164+
let bundledPackageJson;
165+
try {
166+
bundledPackageJson = JSON.parse(readText(manifestPath));
167+
} catch (error) {
168+
errors.push(
169+
`unreadable bundled ${name} package.json: ${
170+
error instanceof Error ? error.message : String(error)
171+
}`,
172+
);
173+
return errors;
174+
}
175+
if (bundledPackageJson.name !== name) {
176+
errors.push(`bundled ${name} package.json must name ${name}`);
177+
}
178+
const runtimeEntries = REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES.get(name) ?? [];
179+
const resolutions = resolveBundledPackageSpecifiers(
180+
packageRoot,
181+
runtimeEntries.map(({ specifier }) => specifier),
182+
);
183+
if (!resolutions) {
184+
errors.push(`bundled ${name} runtime specifier resolution failed`);
185+
}
186+
for (const { entry, specifier } of runtimeEntries) {
187+
if (!entries.has(`${packagePrefix}${entry}`)) {
188+
errors.push(`bundled ${name} is missing required runtime entry ${entry}`);
189+
}
190+
const resolvedUrl = resolutions?.[specifier] ?? "";
191+
if (!resolvedUrl) {
192+
errors.push(`bundled ${name} runtime specifier ${specifier} is not resolvable`);
193+
continue;
194+
}
195+
const expectedUrl = pathToFileURL(path.join(packageRoot, packagePrefix, entry)).href;
196+
if (resolvedUrl !== expectedUrl) {
197+
errors.push(
198+
`bundled ${name} runtime specifier ${specifier} resolves to ${resolvedUrl} instead of ${expectedUrl}`,
199+
);
200+
}
201+
}
202+
const bundledFiles = files
203+
.filter((file) => file.startsWith(packagePrefix))
204+
.map((file) => file.slice(packagePrefix.length));
205+
errors.push(
206+
...collectPackageDistImportErrors({
207+
files: bundledFiles,
208+
readText: (file) => readText(`${packagePrefix}${file}`),
209+
}).map((error) => `bundled ${name} ${error}`),
210+
);
211+
return errors;
212+
}
213+
214+
function collectRequiredBundledWorkspaceDependencyErrors(
215+
packageJson,
216+
entrySet,
217+
files,
218+
packageRoot,
219+
readText,
220+
) {
115221
const errors = [];
116222
if (!packageJson || typeof packageJson !== "object") {
117223
return errors;
@@ -134,7 +240,17 @@ function collectRequiredBundledWorkspaceDependencyErrors(packageJson, entrySet)
134240
}
135241
if (!entrySet.has(`node_modules/${name}/package.json`)) {
136242
errors.push(`package.json dependencies.${name} must be bundled in node_modules/${name}`);
243+
continue;
137244
}
245+
errors.push(
246+
...collectBundledPackageRuntimeErrors({
247+
name,
248+
entries: entrySet,
249+
files,
250+
packageRoot,
251+
readText,
252+
}),
253+
);
138254
}
139255

140256
return errors;
@@ -273,6 +389,12 @@ function readTarEntry(entryPath) {
273389
return "";
274390
}
275391

392+
const extractedPackageRoot = fs.realpathSync(
393+
fs.existsSync(path.join(extractDir, "package", "package.json"))
394+
? path.join(extractDir, "package")
395+
: extractDir,
396+
);
397+
276398
for (const entry of normalized) {
277399
if (entry.startsWith("/") || entry.split("/").includes("..")) {
278400
errors.push(`unsafe tar entry: ${entry}`);
@@ -302,7 +424,15 @@ if (entrySet.has("package.json")) {
302424
packageVersion = typeof packageJson.version === "string" ? packageJson.version : "";
303425
errors.push(...collectWorkspaceProtocolDependencyErrors(packageJson, "package.json"));
304426
if (cliArgs.requireBundledWorkspaceDeps) {
305-
errors.push(...collectRequiredBundledWorkspaceDependencyErrors(packageJson, entrySet));
427+
errors.push(
428+
...collectRequiredBundledWorkspaceDependencyErrors(
429+
packageJson,
430+
entrySet,
431+
normalized,
432+
extractedPackageRoot,
433+
readTarEntry,
434+
),
435+
);
306436
}
307437
} catch {
308438
packageVersion = "";

scripts/docker/install-sh-smoke/run.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ print_install_audit() {
9999
fi
100100
}
101101

102+
verify_candidate_ai_runtime() {
103+
echo "==> Verify installed AI runtime"
104+
OPENCLAW_ALLOW_ROOT=1 openclaw infer image providers --json >/dev/null
105+
}
106+
102107
run_with_heartbeat() {
103108
local label="$1"
104109
shift
@@ -278,6 +283,7 @@ run_install_smoke() {
278283
fi
279284
fi
280285
verify_installed_cli "$PACKAGE_NAME" "$FRESH_VERSION"
286+
verify_candidate_ai_runtime
281287

282288
echo "OK"
283289
return 0
@@ -480,6 +486,7 @@ NODE
480486
echo "==> Verify updated version"
481487
print_install_audit "updated install"
482488
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
489+
verify_candidate_ai_runtime
483490

484491
echo "OK"
485492
}

scripts/e2e/bun-global-install-smoke.sh

Lines changed: 2 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
set -euo pipefail
33

44
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5-
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
5+
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
66

77
read_positive_int_env() {
88
local name="${1:?missing environment variable name}"
@@ -78,113 +78,6 @@ run_with_timeout() {
7878
node scripts/e2e/lib/bun-global-install/assertions.mjs run-with-timeout "$timeout_ms" "$@"
7979
}
8080

81-
restore_dist_from_image() {
82-
local image="$1"
83-
local ai_backup_dir=""
84-
local ai_dist_installed=0
85-
local backup_dir=""
86-
local container_id=""
87-
local dist_installed=0
88-
local restore_complete=0
89-
local temp_dir=""
90-
91-
cleanup_restore_dist() {
92-
if [ -n "$container_id" ]; then
93-
docker_e2e_docker_cmd rm -f "$container_id" >/dev/null 2>&1 || true
94-
fi
95-
# Both build trees come from one image. A partial swap must restore both or
96-
# the following package step could mix artifacts from different builds.
97-
if [ "$restore_complete" != "1" ]; then
98-
if [ "$dist_installed" = "1" ]; then
99-
rm -rf "$ROOT_DIR/dist" >/dev/null 2>&1 || true
100-
fi
101-
if [ -n "$backup_dir" ] && [ -d "$backup_dir" ]; then
102-
if [ ! -e "$ROOT_DIR/dist" ] && mv "$backup_dir" "$ROOT_DIR/dist" >/dev/null 2>&1; then
103-
backup_dir=""
104-
fi
105-
fi
106-
if [ "$ai_dist_installed" = "1" ]; then
107-
rm -rf "$ROOT_DIR/packages/ai/dist" >/dev/null 2>&1 || true
108-
fi
109-
if [ -n "$ai_backup_dir" ] && [ -d "$ai_backup_dir" ]; then
110-
if [ ! -e "$ROOT_DIR/packages/ai/dist" ] && \
111-
mv "$ai_backup_dir" "$ROOT_DIR/packages/ai/dist" >/dev/null 2>&1; then
112-
ai_backup_dir=""
113-
fi
114-
fi
115-
fi
116-
if [ -n "$temp_dir" ]; then
117-
rm -rf "$temp_dir"
118-
fi
119-
if [ "$restore_complete" = "1" ] && [ -n "$backup_dir" ]; then
120-
rm -rf "$backup_dir"
121-
fi
122-
if [ "$restore_complete" = "1" ] && [ -n "$ai_backup_dir" ]; then
123-
rm -rf "$ai_backup_dir"
124-
fi
125-
}
126-
127-
echo "==> Reuse dist/ from Docker image: $image"
128-
if ! container_id="$(docker_e2e_docker_cmd create "$image")"; then
129-
cleanup_restore_dist
130-
return 1
131-
fi
132-
if ! temp_dir="$(mktemp -d "$ROOT_DIR/.bun-dist.XXXXXX")"; then
133-
cleanup_restore_dist
134-
return 1
135-
fi
136-
if ! docker_e2e_docker_cmd cp "${container_id}:/app/dist" "$temp_dir/dist"; then
137-
cleanup_restore_dist
138-
return 1
139-
fi
140-
if ! docker_e2e_docker_cmd cp \
141-
"${container_id}:/app/node_modules/@openclaw/ai/dist" \
142-
"$temp_dir/ai-dist"; then
143-
cleanup_restore_dist
144-
return 1
145-
fi
146-
if [ -e "$ROOT_DIR/dist" ]; then
147-
if ! backup_dir="$(mktemp -d "$ROOT_DIR/.dist-backup.XXXXXX")"; then
148-
cleanup_restore_dist
149-
return 1
150-
fi
151-
if ! rmdir "$backup_dir"; then
152-
cleanup_restore_dist
153-
return 1
154-
fi
155-
if ! mv "$ROOT_DIR/dist" "$backup_dir"; then
156-
cleanup_restore_dist
157-
return 1
158-
fi
159-
fi
160-
if ! mv "$temp_dir/dist" "$ROOT_DIR/dist"; then
161-
cleanup_restore_dist
162-
return 1
163-
fi
164-
dist_installed=1
165-
if [ -e "$ROOT_DIR/packages/ai/dist" ]; then
166-
if ! ai_backup_dir="$(mktemp -d "$ROOT_DIR/packages/ai/.dist-backup.XXXXXX")"; then
167-
cleanup_restore_dist
168-
return 1
169-
fi
170-
if ! rmdir "$ai_backup_dir"; then
171-
cleanup_restore_dist
172-
return 1
173-
fi
174-
if ! mv "$ROOT_DIR/packages/ai/dist" "$ai_backup_dir"; then
175-
cleanup_restore_dist
176-
return 1
177-
fi
178-
fi
179-
if ! mv "$temp_dir/ai-dist" "$ROOT_DIR/packages/ai/dist"; then
180-
cleanup_restore_dist
181-
return 1
182-
fi
183-
ai_dist_installed=1
184-
restore_complete=1
185-
cleanup_restore_dist
186-
}
187-
18881
resolve_package_tgz() {
18982
if [ -n "$PACKAGE_TGZ" ]; then
19083
if [ ! -f "$PACKAGE_TGZ" ]; then
@@ -196,7 +89,7 @@ resolve_package_tgz() {
19689
fi
19790

19891
if [ -n "$DIST_IMAGE" ]; then
199-
restore_dist_from_image "$DIST_IMAGE"
92+
docker_e2e_restore_package_dist_from_image "$DIST_IMAGE"
20093
elif [ "$HOST_BUILD" != "0" ]; then
20194
echo "==> Build host package artifacts"
20295
pnpm build

0 commit comments

Comments
 (0)